aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorXin Li <delphij@google.com>2021-06-18 12:24:40 -0700
committerXin Li <delphij@google.com>2021-06-18 12:24:40 -0700
commit9ebbbcecd465420695bb7822cad2006a02572225 (patch)
tree94f954ae9d123fbb823cec1187d7fa81791795ce
parent53f14a3035ef8449eb9eda71e67c97edfa034611 (diff)
parent56c499999b12691b9cd71081a78b76d894721f4a (diff)
downloadplatform_packages_apps_Car_RotaryController-master.tar.gz
platform_packages_apps_Car_RotaryController-master.tar.bz2
platform_packages_apps_Car_RotaryController-master.zip
Bug: 190855093 Merged-In: Ibfb65007b2f1e9742e18b4453559bc3c4926b743 Change-Id: I3b84888b472d63a02e859a79e0ed160f34e87f36
-rw-r--r--Android.bp41
-rw-r--r--AndroidManifest.xml10
-rw-r--r--res/values/integers.xml24
-rw-r--r--res/values/strings.xml9
-rw-r--r--src/com/android/car/rotary/FocusFinder.java77
-rw-r--r--src/com/android/car/rotary/L.java9
-rw-r--r--src/com/android/car/rotary/Navigator.java271
-rw-r--r--src/com/android/car/rotary/RotaryService.java879
-rw-r--r--src/com/android/car/rotary/SurfaceViewHelper.java100
-rw-r--r--src/com/android/car/rotary/TreeTraverser.java2
-rw-r--r--src/com/android/car/rotary/Utils.java64
-rw-r--r--src/com/android/car/rotary/WindowCache.java16
-rw-r--r--tests/robotests/Android.bp25
-rw-r--r--tests/robotests/AndroidManifest.xml19
-rw-r--r--tests/robotests/config/robolectric.properties16
-rw-r--r--tests/robotests/src/com/android/car/rotary/NavigatorTest.java801
-rw-r--r--tests/robotests/src/com/android/car/rotary/TreeTraverserTest.java282
-rw-r--r--tests/unit/Android.bp8
-rw-r--r--tests/unit/AndroidManifest.xml12
-rw-r--r--tests/unit/res/layout/navigator_find_focus_parking_view_test_activity.xml47
-rw-r--r--tests/unit/res/layout/navigator_find_focusable_descendant_empty_bounds_test_activity.xml62
-rw-r--r--tests/unit/res/layout/navigator_find_focusable_descendant_test_activity.xml67
-rw-r--r--tests/unit/res/layout/navigator_find_nudge_target_focus_area_1_test_activity.xml43
-rw-r--r--tests/unit/res/layout/navigator_find_nudge_target_focus_area_2_test_activity.xml110
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_does_not_skip_offscreen_node_test_activity.xml65
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_1_activity.xml76
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_2_activity.xml66
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_activity.xml53
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_generic_fpv_activity.xml53
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_activity.xml46
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_generic_fpv_activity.xml46
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_one_node_test_activity.xml27
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_scrollable_container_test_activity.xml55
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_1_activity.xml57
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_2_activity.xml56
-rw-r--r--tests/unit/res/layout/navigator_find_rotate_target_test_activity.xml53
-rw-r--r--tests/unit/res/layout/navigator_find_scrollable_container_test_activity.xml67
-rw-r--r--tests/unit/res/layout/navigator_get_ancestor_focus_area_test_activity.xml62
-rw-r--r--tests/unit/res/layout/rotary_service_test_1_activity.xml57
-rw-r--r--tests/unit/res/layout/rotary_service_test_2_activity.xml137
-rw-r--r--tests/unit/res/layout/test_recycler_view_view_holder.xml21
-rw-r--r--tests/unit/res/layout/tree_traverser_test_activity.xml77
-rwxr-xr-xtests/unit/src/com/android/car/rotary/FocusFinderTest.java (renamed from tests/robotests/src/com/android/car/rotary/FocusFinderTest.java)37
-rw-r--r--tests/unit/src/com/android/car/rotary/MockNodeCopierProvider.java (renamed from tests/robotests/src/com/android/car/rotary/MockNodeCopierProvider.java)14
-rw-r--r--tests/unit/src/com/android/car/rotary/NavigatorTest.java1502
-rw-r--r--tests/unit/src/com/android/car/rotary/NavigatorTestActivity.java44
-rw-r--r--tests/unit/src/com/android/car/rotary/NodeBuilder.java (renamed from tests/robotests/src/com/android/car/rotary/NodeBuilder.java)102
-rw-r--r--tests/unit/src/com/android/car/rotary/NodeBuilderTest.java (renamed from tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java)72
-rw-r--r--tests/unit/src/com/android/car/rotary/RotaryServiceTest.java1953
-rw-r--r--tests/unit/src/com/android/car/rotary/SurfaceViewHelperTest.java79
-rw-r--r--tests/unit/src/com/android/car/rotary/TreeTraverserTest.java316
-rw-r--r--tests/unit/src/com/android/car/rotary/TreeTraverserTestActivity.java33
-rw-r--r--tests/unit/src/com/android/car/rotary/WindowBuilder.java (renamed from tests/robotests/src/com/android/car/rotary/WindowBuilder.java)24
-rw-r--r--tests/unit/src/com/android/car/rotary/WindowBuilderTest.java (renamed from tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java)17
-rw-r--r--tests/unit/src/com/android/car/rotary/WindowCacheTest.java (renamed from tests/robotests/src/com/android/car/rotary/WindowCacheTest.java)7
-rw-r--r--tests/unit/src/com/android/car/rotary/ui/FocusParkingView.java113
-rw-r--r--tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewAdapter.java68
-rw-r--r--tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewViewHolder.java41
58 files changed, 7065 insertions, 1425 deletions
diff --git a/Android.bp b/Android.bp
index 5390295..42011cd 100644
--- a/Android.bp
+++ b/Android.bp
@@ -21,6 +21,9 @@ android_app {
srcs: ["src/**/*.java"],
resource_dirs: ["res"],
+ // This app uses allowlisted privileged permissions.
+ required: ["allowed_privapp_com.android.car.rotary"],
+
// Because it uses a platform API (CarInputManager).
platform_apis: true,
@@ -28,6 +31,42 @@ android_app {
// permission, which is of type "signature".
certificate: "platform",
+ // This app uses allowlisted privileged permissions.
+ privileged: true,
+
+ optimize: {
+ enabled: false,
+ },
+ dex_preopt: {
+ enabled: false,
+ },
+ libs: [
+ "android.car",
+ ],
+ static_libs: [
+ "car-ui-lib",
+ ],
+ product_variables: {
+ pdk: {
+ enabled: false,
+ },
+ },
+}
+
+android_library {
+ name: "CarRotaryControllerForUnitTesting",
+
+ manifest: "tests/unit/AndroidManifest.xml",
+
+ srcs: ["src/**/*.java"],
+
+ resource_dirs: [
+ "tests/unit/res",
+ "res",
+ ],
+
+ platform_apis: true,
+
optimize: {
enabled: false,
},
@@ -45,4 +84,6 @@ android_app {
enabled: false,
},
},
+
+ aaptflags: ["--extra-packages com.android.car.rotary"],
}
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 50cf3b2..12e62fd 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -35,6 +35,16 @@
<!-- Allows us to get the username of the current user. -->
<uses-permission android:name="android.permission.GET_ACCOUNTS_PRIVILEGED"/>
+ <!-- Allows us to read the current activity's metadata for custom nudge actions. -->
+ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
+
+ <!-- Ensures the host app is visible to RotaryService. -->
+ <queries>
+ <intent>
+ <action android:name="android.car.template.host.RendererService" />
+ </intent>
+ </queries>
+
<!-- RotaryService needs to be directBootAware so that it can start before the user is unlocked. -->
<application>
<service
diff --git a/res/values/integers.xml b/res/values/integers.xml
index 8d95cae..91c3f4a 100644
--- a/res/values/integers.xml
+++ b/res/values/integers.xml
@@ -36,4 +36,28 @@
these events so this needs to be fairly long.
-->
<integer name="after_focus_timeout_ms">60000</integer>
+
+ <!-- How many milliseconds the center button must be held down to trigger a long-press. Zero
+ indicates the system default long-press timeout should be used. -->
+ <integer name="long_press_ms">0</integer>
+
+ <!-- Global actions to perform when the user nudges up, down, left, or right off the edge of the
+ screen. No global action is performed if the relevant element of this array is -1
+ (INVALID_GLOBAL_ACTION). -->
+ <integer-array name="off_screen_nudge_global_actions">
+ <item>-1</item>
+ <item>-1</item>
+ <item>-1</item>
+ <item>-1</item>
+ </integer-array>
+
+ <!-- Key codes of click events to inject when the user nudges up, down, left, or right off the
+ edge of the screen. No event is injected if the relevant element of this array is 0
+ (KEYCODE_UNKNOWN). -->
+ <integer-array name="off_screen_nudge_key_codes">
+ <item>0</item>
+ <item>0</item>
+ <item>0</item>
+ <item>0</item>
+ </integer-array>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 72f8169..6d06a1a 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -20,4 +20,13 @@
<string name="default_touch_input_method" translatable="false">com.android.inputmethod.latin/.CarLatinIME</string>
<!-- Component name of rotary IME. Empty if none. -->
<string name="rotary_input_method" translatable="false"></string>
+
+ <!-- Intents to launch an activity when the user nudges up, down, left, or right off the edge of
+ the screen. No activity is launched if the relevant element of this array is empty. -->
+ <string-array name="off_screen_nudge_intents" translatable="false">
+ <item></item>
+ <item></item>
+ <item></item>
+ <item></item>
+ </string-array>
</resources>
diff --git a/src/com/android/car/rotary/FocusFinder.java b/src/com/android/car/rotary/FocusFinder.java
index a49abcb..23195c4 100644
--- a/src/com/android/car/rotary/FocusFinder.java
+++ b/src/com/android/car/rotary/FocusFinder.java
@@ -34,6 +34,10 @@ class FocusFinder {
*/
private static final long MAJOR_AXIS_BIAS = 13;
+ private static final int[] DIRECTIONS = new int[]{
+ View.FOCUS_LEFT, View.FOCUS_RIGHT, View.FOCUS_UP, View.FOCUS_DOWN
+ };
+
/**
* Returns whether part of {@code destRect} is in {@code direction} of part of {@code srcRect}.
*
@@ -58,7 +62,8 @@ class FocusFinder {
}
/**
- * Returns whether part of {@code destRect} is in {@code direction} of {@code srcRect}.
+ * Returns whether part of {@code destRect} is in {@code direction} of {@code srcRect} and
+ * {@code destRect} is not strictly in any of other 3 directions of {@code srcRect}.
*
* @param srcRect the source rectangle
* @param destRect the destination rectangle
@@ -66,6 +71,22 @@ class FocusFinder {
* {@link View#FOCUS_LEFT}, or {@link View#FOCUS_RIGHT}
*/
static boolean isInDirection(Rect srcRect, Rect destRect, int direction) {
+ // If destRect is strictly in the given direction of srcRect, destRect is in the given
+ // direction of srcRect.
+ if (isStrictlyInDirection(srcRect, destRect, direction)) {
+ return true;
+ }
+
+ // If destRect is strictly in any of the other directions of srcRect, destRect is not in
+ // the given direction of srcRect.
+ for (int i = 0; i < DIRECTIONS.length; i++) {
+ if (direction != DIRECTIONS[i]
+ && isStrictlyInDirection(srcRect, destRect, DIRECTIONS[i])) {
+ return false;
+ }
+ }
+
+ // Otherwise check whether part of destRect is in the given direction of srcRect.
switch (direction) {
case View.FOCUS_LEFT:
return destRect.left < srcRect.left;
@@ -303,4 +324,58 @@ class FocusFinder {
throw new IllegalArgumentException("direction must be one of "
+ "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT}.");
}
+
+ /**
+ * Returns whether {@code destRect} is strictly in {@code direction} of {@code srcRect}.
+ * <p>
+ * For example, iff {@code destRect} is strictly to the {@link View#FOCUS_LEFT} of {@code
+ * srcRect}, the following conditions must be true:
+ * <ul>
+ * <li> {@code destRect.left} is on the left of {@code srcRect.left}
+ * <li> {@code destRect.right} overlaps with or is on the left of {@code srcRect.right}
+ * <li> [{@code destRect.top}, {@code destRect.bottom}] contains or is contained by
+ * [{@code srcRect.top}, {@code srcRect.bottom}]
+ * </ul>
+ *
+ * @param srcRect the source rectangle
+ * @param destRect the destination rectangle
+ * @param direction must be {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN},
+ * {@link View#FOCUS_LEFT}, or {@link View#FOCUS_RIGHT}
+ */
+ private static boolean isStrictlyInDirection(Rect srcRect, Rect destRect, int direction) {
+ switch (direction) {
+ case View.FOCUS_LEFT:
+ return destRect.left < srcRect.left && destRect.right <= srcRect.right
+ && containsOrIsContainedVertically(srcRect, destRect);
+ case View.FOCUS_RIGHT:
+ return destRect.right > srcRect.right && destRect.left >= srcRect.left
+ && containsOrIsContainedVertically(srcRect, destRect);
+ case View.FOCUS_UP:
+ return destRect.top < srcRect.top && destRect.bottom <= srcRect.bottom
+ && containsOrIsContainedHorizontally(srcRect, destRect);
+ case View.FOCUS_DOWN:
+ return destRect.bottom > srcRect.bottom && destRect.top >= srcRect.top
+ && containsOrIsContainedHorizontally(srcRect, destRect);
+ }
+ throw new IllegalArgumentException("direction must be "
+ + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
+ }
+
+ /**
+ * Returns true if the projection of {@code rect1} on the Y-axis contains or is contained by the
+ * projection of {@code rect2} on the Y-axis.
+ */
+ private static boolean containsOrIsContainedVertically(Rect rect1, Rect rect2) {
+ return (rect1.top <= rect2.top && rect1.bottom >= rect2.bottom)
+ || (rect2.top <= rect1.top && rect2.bottom >= rect1.bottom);
+ }
+
+ /**
+ * Returns true if the projection of {@code rect1} on the X-axis contains or is contained by the
+ * projection of {@code rect2} on the X-axis.
+ */
+ private static boolean containsOrIsContainedHorizontally(Rect rect1, Rect rect2) {
+ return (rect1.left <= rect2.left && rect1.right >= rect2.right)
+ || (rect2.left <= rect1.left && rect2.right >= rect1.right);
+ }
}
diff --git a/src/com/android/car/rotary/L.java b/src/com/android/car/rotary/L.java
index da46ba7..6c712c5 100644
--- a/src/com/android/car/rotary/L.java
+++ b/src/com/android/car/rotary/L.java
@@ -61,4 +61,13 @@ class L {
Log.e(TAG, msg);
}
}
+
+ /** Logs conditional logs if loggable or on a debug build. */
+ static void successOrFailure(@NonNull String msg, boolean success) {
+ if (success) {
+ d(msg + " succeeded");
+ } else {
+ w(msg + " failed");
+ }
+ }
}
diff --git a/src/com/android/car/rotary/Navigator.java b/src/com/android/car/rotary/Navigator.java
index 81f11a5..f2615a3 100644
--- a/src/com/android/car/rotary/Navigator.java
+++ b/src/com/android/car/rotary/Navigator.java
@@ -17,9 +17,11 @@ package com.android.car.rotary;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
+import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD;
+import android.content.pm.PackageManager;
import android.graphics.Rect;
import android.view.Display;
import android.view.View;
@@ -33,6 +35,8 @@ import androidx.annotation.VisibleForTesting;
import com.android.car.ui.FocusArea;
import com.android.car.ui.FocusParkingView;
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@@ -50,6 +54,10 @@ class Navigator {
@NonNull
private final TreeTraverser mTreeTraverser = new TreeTraverser();
+ @NonNull
+ @VisibleForTesting
+ final SurfaceViewHelper mSurfaceViewHelper = new SurfaceViewHelper();
+
private final int mHunLeft;
private final int mHunRight;
@@ -67,6 +75,36 @@ class Navigator {
mAppWindowBounds = new Rect(0, 0, displayWidth, displayHeight);
}
+ @VisibleForTesting
+ Navigator() {
+ this(0, 0, 0, 0, false);
+ }
+
+ /** Initializes the package name of the host app. */
+ void initHostApp(@NonNull PackageManager packageManager) {
+ mSurfaceViewHelper.initHostApp(packageManager);
+ }
+
+ /** Clears the package name of the host app if the given {@code packageName} matches. */
+ void clearHostApp(@NonNull String packageName) {
+ mSurfaceViewHelper.clearHostApp(packageName);
+ }
+
+ /** Adds the package name of the client app. */
+ void addClientApp(@NonNull CharSequence clientAppPackageName) {
+ mSurfaceViewHelper.addClientApp(clientAppPackageName);
+ }
+
+ /** Returns whether the given {@code node} represents a view of the host app. */
+ boolean isHostNode(@NonNull AccessibilityNodeInfo node) {
+ return mSurfaceViewHelper.isHostNode(node);
+ }
+
+ /** Returns whether the given {@code node} represents a view of the client app. */
+ boolean isClientNode(@NonNull AccessibilityNodeInfo node) {
+ return mSurfaceViewHelper.isClientNode(node);
+ }
+
@Nullable
AccessibilityWindowInfo findHunWindow(@NonNull List<AccessibilityWindowInfo> windows) {
for (AccessibilityWindowInfo window : windows) {
@@ -184,7 +222,7 @@ class Navigator {
return target == null ? null : new FindRotateTargetResult(target, advancedCount);
}
- /** Sets a mock Utils instance for testing. */
+ /** Sets a NodeCopier instance for testing. */
@VisibleForTesting
void setNodeCopier(@NonNull NodeCopier nodeCopier) {
mNodeCopier = nodeCopier;
@@ -192,32 +230,149 @@ class Navigator {
}
/**
- * Searches the window containing {@code node}, and returns the node representing a {@link
- * FocusParkingView}, if any, or returns null if not found. The caller is responsible for
+ * Returns the root node in the tree containing {@code node}. The caller is responsible for
* recycling the result.
*/
- @Nullable
- AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) {
+ @NonNull
+ AccessibilityNodeInfo getRoot(@NonNull AccessibilityNodeInfo node) {
+ // If the node represents a view in the embedded view hierarchy hosted by a SurfaceView,
+ // return the root node of the hierarchy, which is the only child of the SurfaceView node.
+ if (isHostNode(node)) {
+ AccessibilityNodeInfo child = mNodeCopier.copy(node);
+ AccessibilityNodeInfo parent = node.getParent();
+ while (parent != null && !Utils.isSurfaceView(parent)) {
+ child.recycle();
+ child = parent;
+ parent = child.getParent();
+ }
+ Utils.recycleNode(parent);
+ return child;
+ }
+
+ // Get the root node directly via the window.
AccessibilityWindowInfo window = node.getWindow();
- if (window == null) {
- L.w("Failed to get window for node " + node);
+ if (window != null) {
+ AccessibilityNodeInfo root = window.getRoot();
+ window.recycle();
+ if (root != null) {
+ return root;
+ }
+ }
+
+ // If the root node can't be accessed via the window, navigate up the node tree.
+ AccessibilityNodeInfo child = mNodeCopier.copy(node);
+ AccessibilityNodeInfo parent = node.getParent();
+ while (parent != null) {
+ child.recycle();
+ child = parent;
+ parent = child.getParent();
+ }
+ return child;
+ }
+
+ /**
+ * Searches {@code root} and its descendants, and returns the currently focused node if it's
+ * not a {@link FocusParkingView}, or returns null in other cases. The caller is responsible
+ * for recycling the result.
+ */
+ @Nullable
+ AccessibilityNodeInfo findFocusedNodeInRoot(@NonNull AccessibilityNodeInfo root) {
+ AccessibilityNodeInfo focusedNode = findFocusedNodeInRootInternal(root);
+ if (focusedNode != null && Utils.isFocusParkingView(focusedNode)) {
+ focusedNode.recycle();
+ return null;
+ }
+ return focusedNode;
+ }
+
+ /**
+ * Searches {@code root} and its descendants, and returns the currently focused node, if any,
+ * or returns null if not found. The caller is responsible for recycling the result.
+ */
+ @Nullable
+ private AccessibilityNodeInfo findFocusedNodeInRootInternal(
+ @NonNull AccessibilityNodeInfo root) {
+ AccessibilityNodeInfo surfaceView = null;
+ if (!isClientNode(root)) {
+ AccessibilityNodeInfo focusedNode = root.findFocus(FOCUS_INPUT);
+ if (focusedNode != null && Utils.isSurfaceView(focusedNode)) {
+ // The focused node represents a SurfaceView. In this case the root node is actually
+ // a client node but Navigator doesn't know that because SurfaceViewHelper doesn't
+ // know the package name of the client app.
+ // Although the package name of the client app will be stored in SurfaceViewHelper
+ // when RotaryService handles TYPE_WINDOW_STATE_CHANGED event, RotaryService may not
+ // receive the event. For example, RotaryService may have been killed and restarted.
+ // In this case, Navigator should store the package name.
+ surfaceView = focusedNode;
+ addClientApp(surfaceView.getPackageName());
+ } else {
+ return focusedNode;
+ }
+ }
+
+ // The root node is in client app, which contains a SurfaceView to display the embedded
+ // view hierarchy. In this case only search inside the embedded view hierarchy.
+ if (surfaceView == null) {
+ surfaceView = findSurfaceViewInRoot(root);
+ }
+ if (surfaceView == null) {
+ L.w("Failed to find SurfaceView in client app " + root);
return null;
}
- AccessibilityNodeInfo root = window.getRoot();
- window.recycle();
- if (root == null) {
- L.e("No root node that contains " + node);
+ if (surfaceView.getChildCount() == 0) {
+ L.d("Host app is not loaded yet");
+ surfaceView.recycle();
return null;
}
- AccessibilityNodeInfo fpv = mTreeTraverser.depthFirstSearch(
- root,
- /* skipPredicate= */ Utils::isFocusArea,
- /* targetPredicate= */ Utils::isFocusParkingView);
+ AccessibilityNodeInfo embeddedRoot = surfaceView.getChild(0);
+ surfaceView.recycle();
+ if (embeddedRoot == null) {
+ L.w("Failed to get the root of host app");
+ return null;
+ }
+ AccessibilityNodeInfo focusedNode = embeddedRoot.findFocus(FOCUS_INPUT);
+ embeddedRoot.recycle();
+ return focusedNode;
+ }
+
+ /**
+ * Searches the window containing {@code node}, and returns the node representing a {@link
+ * FocusParkingView}, if any, or returns null if not found. The caller is responsible for
+ * recycling the result.
+ */
+ @Nullable
+ AccessibilityNodeInfo findFocusParkingView(@NonNull AccessibilityNodeInfo node) {
+ AccessibilityNodeInfo root = getRoot(node);
+ AccessibilityNodeInfo fpv = findFocusParkingViewInRoot(root);
root.recycle();
return fpv;
}
/**
+ * Searches {@code root} and its descendants, and returns the node representing a {@link
+ * FocusParkingView}, if any, or returns null if not found. The caller is responsible for
+ * recycling the result.
+ */
+ @Nullable
+ AccessibilityNodeInfo findFocusParkingViewInRoot(@NonNull AccessibilityNodeInfo root) {
+ return mTreeTraverser.depthFirstSearch(
+ root,
+ /* skipPredicate= */ Utils::isFocusArea,
+ /* targetPredicate= */ Utils::isFocusParkingView
+ );
+ }
+
+ /**
+ * Searches {@code root} and its descendants, and returns the node representing a {@link
+ * android.view.SurfaceView}, if any, or returns null if not found. The caller is responsible
+ * for recycling the result.
+ */
+ @Nullable
+ AccessibilityNodeInfo findSurfaceViewInRoot(@NonNull AccessibilityNodeInfo root) {
+ return mTreeTraverser.depthFirstSearch(root, /* targetPredicate= */ Utils::isSurfaceView);
+ }
+
+ /**
* Returns the best target focus area for a nudge in the given {@code direction}. The caller is
* responsible for recycling the result.
*
@@ -351,11 +506,21 @@ class Navigator {
* them. If there are no explicitly declared {@link FocusArea}s, returns the root view. The
* caller is responsible for recycling the result.
*/
- private @NonNull
+ @NonNull
+ @VisibleForTesting
List<AccessibilityNodeInfo> findFocusAreas(@NonNull AccessibilityWindowInfo window) {
List<AccessibilityNodeInfo> results = new ArrayList<>();
AccessibilityNodeInfo rootNode = window.getRoot();
if (rootNode != null) {
+ // If the root node is in the client app therefore contains a SurfaceView, skip the view
+ // hierarchy of the client app, and scan the view hierarchy of the host app, which is
+ // embedded in the SurfaceView.
+ if (isClientNode(rootNode)) {
+ AccessibilityNodeInfo hostRoot = getDescendantHostRoot(rootNode);
+ rootNode.recycle();
+ rootNode = hostRoot;
+ }
+
addFocusAreas(rootNode, results);
if (results.isEmpty()) {
results.add(copyNode(rootNode));
@@ -366,13 +531,22 @@ class Navigator {
}
/**
+ * Searches from {@code clientNode}, and returns the root of the embedded view hierarchy if any,
+ * or returns null if not found. The caller is responsible for recycling the result.
+ */
+ @Nullable
+ AccessibilityNodeInfo getDescendantHostRoot(@NonNull AccessibilityNodeInfo clientNode) {
+ return mTreeTraverser.depthFirstSearch(clientNode, this::isHostNode);
+ }
+
+ /**
* Returns whether the given window is the Heads-up Notification (HUN) window. The HUN window
* is identified by the left and right edges. The top and bottom vary depending on whether the
* HUN appears at the top or bottom of the screen and on the height of the notification being
* displayed so they aren't used.
*/
- boolean isHunWindow(@NonNull AccessibilityWindowInfo window) {
- if (window.getType() != AccessibilityWindowInfo.TYPE_SYSTEM) {
+ boolean isHunWindow(@Nullable AccessibilityWindowInfo window) {
+ if (window == null || window.getType() != AccessibilityWindowInfo.TYPE_SYSTEM) {
return false;
}
Rect bounds = new Rect();
@@ -381,6 +555,18 @@ class Navigator {
}
/**
+ * Returns whether the {@code window} is the main application window. A main application
+ * window is an application window on the default display that takes up the entire display.
+ */
+ boolean isMainApplicationWindow(@NonNull AccessibilityWindowInfo window) {
+ Rect windowBounds = new Rect();
+ window.getBoundsInScreen(windowBounds);
+ return window.getType() == TYPE_APPLICATION
+ && window.getDisplayId() == Display.DEFAULT_DISPLAY
+ && mAppWindowBounds.equals(windowBounds);
+ }
+
+ /**
* Searches from the given node up through its ancestors to the containing focus area, looking
* for a node that's marked as horizontally or vertically scrollable. Returns a copy of the
* first such node or null if none is found. The caller is responsible for recycling the result.
@@ -538,6 +724,11 @@ class Navigator {
if (isInWebView(candidateNode)) {
return Utils.canPerformFocus(candidateNode);
}
+ // If a node isn't visible to the user, e.g. another window is obscuring it,
+ // skip it.
+ if (!candidateNode.isVisibleToUser()) {
+ return false;
+ }
// If a node can't take focus, it represents a focus area, so we return false to
// skip the node and let it search its descendants.
if (!Utils.canTakeFocus(candidateNode)) {
@@ -559,10 +750,17 @@ class Navigator {
}
/**
- * Finds the closest ancestor focus area of the given {@code node}. If the given {@code node}
- * is a focus area, returns it; if there are no explicitly declared {@link FocusArea}s among the
- * ancestors of this view, returns the root view. The caller is responsible for recycling the
- * result.
+ * Returns the closest ancestor focus area of the given {@code node}.
+ * <ul>
+ * <li> If the given {@code node} is a {@link FocusArea} node or a descendant of a {@link
+ * FocusArea} node, returns the {@link FocusArea} node.
+ * <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
+ * view, and this view is not in an embedded view hierarchy, returns the root node.
+ * <li> If there are no explicitly declared {@link FocusArea}s among the ancestors of this
+ * view, and this view is in an embedded view hierarchy, returns the root node of
+ * embedded view hierarchy.
+ * </ul>
+ * The caller is responsible for recycling the result.
*/
@NonNull
AccessibilityNodeInfo getAncestorFocusArea(@NonNull AccessibilityNodeInfo node) {
@@ -576,6 +774,11 @@ class Navigator {
// The candidateNode is the root node.
return true;
}
+ if (Utils.isSurfaceView(parent)) {
+ // Treat the root of embedded view hierarchy (i.e., the only child of the
+ // SurfaceView) as an implicit focus area.
+ return true;
+ }
parent.recycle();
return false;
};
@@ -693,6 +896,30 @@ class Navigator {
});
}
+ void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
+ writer.println(" hunLeft: " + mHunLeft + ", right: " + mHunRight);
+ writer.println(" hunNudgeDirection: " + directionToString(mHunNudgeDirection));
+ writer.println(" appWindowBounds: " + mAppWindowBounds);
+
+ writer.println(" surfaceViewHelper:");
+ mSurfaceViewHelper.dump(fd, writer, args);
+ }
+
+ static String directionToString(@View.FocusRealDirection int direction) {
+ switch (direction) {
+ case View.FOCUS_UP:
+ return "FOCUS_UP";
+ case View.FOCUS_DOWN:
+ return "FOCUS_DOWN";
+ case View.FOCUS_LEFT:
+ return "FOCUS_LEFT";
+ case View.FOCUS_RIGHT:
+ return "FOCUS_RIGHT";
+ default:
+ return "<unknown direction " + direction + ">";
+ }
+ }
+
/** Result from {@link #findRotateTarget}. */
static class FindRotateTargetResult {
@NonNull final AccessibilityNodeInfo node;
diff --git a/src/com/android/car/rotary/RotaryService.java b/src/com/android/car/rotary/RotaryService.java
index 42600c4..87dae63 100644
--- a/src/com/android/car/rotary/RotaryService.java
+++ b/src/com/android/car/rotary/RotaryService.java
@@ -17,9 +17,12 @@ package com.android.car.rotary;
import static android.accessibilityservice.AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS;
import static android.provider.Settings.Secure.DEFAULT_INPUT_METHOD;
+import static android.provider.Settings.Secure.DISABLED_SYSTEM_INPUT_METHODS;
+import static android.provider.Settings.Secure.ENABLED_INPUT_METHODS;
import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.KeyEvent.ACTION_DOWN;
import static android.view.KeyEvent.ACTION_UP;
+import static android.view.KeyEvent.KEYCODE_UNKNOWN;
import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
@@ -39,7 +42,6 @@ import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK
import static android.view.accessibility.AccessibilityNodeInfo.ACTION_SELECT;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
-import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD;
@@ -54,9 +56,15 @@ import android.accessibilityservice.AccessibilityServiceInfo;
import android.car.Car;
import android.car.input.CarInputManager;
import android.car.input.RotaryEvent;
-import android.content.ContentResolver;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.graphics.PixelFormat;
@@ -67,6 +75,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.os.Message;
import android.os.SystemClock;
import android.os.UserManager;
import android.provider.Settings;
@@ -77,6 +86,7 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
+import android.view.ViewConfiguration;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
@@ -85,10 +95,15 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import com.android.car.ui.utils.DirectManipulationHelper;
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
import java.lang.ref.WeakReference;
+import java.net.URISyntaxException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -137,10 +152,73 @@ public class RotaryService extends AccessibilityService implements
*/
private static final int INVALID_NUDGE_DIRECTION = -1;
+ /**
+ * Message for timer indicating that the center button has been held down long enough to trigger
+ * a long-press.
+ */
+ private static final int MSG_LONG_PRESS = 1;
+
private static final String SHARED_PREFS = "com.android.car.rotary.RotaryService";
private static final String TOUCH_INPUT_METHOD_PREFIX = "TOUCH_INPUT_METHOD_";
/**
+ * Key for activity metadata indicating that a nudge in the given direction ("up", "down",
+ * "left", or "right") that would otherwise do nothing should trigger a global action, e.g.
+ * {@link #GLOBAL_ACTION_BACK}.
+ */
+ private static final String OFF_SCREEN_NUDGE_GLOBAL_ACTION_FORMAT = "nudge.%s.globalAction";
+ /**
+ * Key for activity metadata indicating that a nudge in the given direction ("up", "down",
+ * "left", or "right") that would otherwise do nothing should trigger a key click, e.g. {@link
+ * KeyEvent#KEYCODE_BACK}.
+ */
+ private static final String OFF_SCREEN_NUDGE_KEY_CODE_FORMAT = "nudge.%s.keyCode";
+ /**
+ * Key for activity metadata indicating that a nudge in the given direction ("up", "down",
+ * "left", or "right") that would otherwise do nothing should launch an activity via an intent.
+ */
+ private static final String OFF_SCREEN_NUDGE_INTENT_FORMAT = "nudge.%s.intent";
+
+ private static final int INVALID_GLOBAL_ACTION = -1;
+
+ private static final int NUM_DIRECTIONS = 4;
+
+ /**
+ * Maps a direction to a string used to look up an off-screen nudge action in an activity's
+ * metadata.
+ *
+ * @see #OFF_SCREEN_NUDGE_GLOBAL_ACTION_FORMAT
+ * @see #OFF_SCREEN_NUDGE_KEY_CODE_FORMAT
+ * @see #OFF_SCREEN_NUDGE_INTENT_FORMAT
+ */
+ private static final Map<Integer, String> DIRECTION_TO_STRING;
+ static {
+ Map<Integer, String> map = new HashMap<>();
+ map.put(View.FOCUS_UP, "up");
+ map.put(View.FOCUS_DOWN, "down");
+ map.put(View.FOCUS_LEFT, "left");
+ map.put(View.FOCUS_RIGHT, "right");
+ DIRECTION_TO_STRING = Collections.unmodifiableMap(map);
+ }
+
+ /**
+ * Maps a direction to an index used to look up an off-screen nudge action in .
+ *
+ * @see #mOffScreenNudgeGlobalActions
+ * @see #mOffScreenNudgeKeyCodes
+ * @see #mOffScreenNudgeIntents
+ */
+ private static final Map<Integer, Integer> DIRECTION_TO_INDEX;
+ static {
+ Map<Integer, Integer> map = new HashMap<>();
+ map.put(View.FOCUS_UP, 0);
+ map.put(View.FOCUS_DOWN, 1);
+ map.put(View.FOCUS_LEFT, 2);
+ map.put(View.FOCUS_RIGHT, 3);
+ DIRECTION_TO_INDEX = Collections.unmodifiableMap(map);
+ }
+
+ /**
* A reference to {@link #mWindowContext} or null if one hasn't been created. This is static in
* order to prevent the creation of multiple window contexts when this service is enabled and
* disabled repeatedly. Android imposes a limit on the number of window contexts without a
@@ -199,7 +277,8 @@ public class RotaryService extends AccessibilityService implements
/**
* The last clicked node by touching the screen, if any were clicked since we last navigated.
*/
- private AccessibilityNodeInfo mLastTouchedNode = null;
+ @VisibleForTesting
+ AccessibilityNodeInfo mLastTouchedNode = null;
/**
* How many milliseconds to ignore {@link AccessibilityEvent#TYPE_VIEW_CLICKED} events after
@@ -213,7 +292,8 @@ public class RotaryService extends AccessibilityService implements
* are ignored if they occur within {@link #mIgnoreViewClickedMs} of {@link
* #mLastViewClickedTime}.
*/
- private AccessibilityNodeInfo mIgnoreViewClickedNode;
+ @VisibleForTesting
+ AccessibilityNodeInfo mIgnoreViewClickedNode;
/**
* The time of the last {@link AccessibilityEvent#TYPE_VIEW_CLICKED} event in {@link
@@ -224,7 +304,10 @@ public class RotaryService extends AccessibilityService implements
/** Component name of rotary IME. Empty if none. */
@Nullable private String mRotaryInputMethod;
- /** Component name of IME used in touch mode. */
+ /** Component name of default IME used in touch mode. */
+ @Nullable private String mDefaultTouchInputMethod;
+
+ /** Component name of current IME used in touch mode. */
@Nullable private String mTouchInputMethod;
/** Observer to update {@link #mTouchInputMethod} when the user switches IMEs. */
@@ -237,16 +320,36 @@ public class RotaryService extends AccessibilityService implements
* The direction of the HUN. If there is no focused node, or the focused node is outside the
* HUN, nudging to this direction will focus on a node inside the HUN.
*/
+ @VisibleForTesting
@View.FocusRealDirection
- private int mHunNudgeDirection;
+ int mHunNudgeDirection;
/**
* The direction to escape the HUN. If the focused node is inside the HUN, nudging to this
* direction will move focus to a node outside the HUN, while nudging to other directions
* will do nothing.
*/
+ @VisibleForTesting
@View.FocusRealDirection
- private int mHunEscapeNudgeDirection;
+ int mHunEscapeNudgeDirection;
+
+ /**
+ * Global actions to perform when the user nudges up, down, left, or right off the edge of the
+ * screen. No global action is performed if the relevant element of this array is
+ * {@link #INVALID_GLOBAL_ACTION}.
+ */
+ private int[] mOffScreenNudgeGlobalActions;
+ /**
+ * Key codes of click events to inject when the user nudges up, down, left, or right off the
+ * edge of the screen. No event is injected if the relevant element of this array is
+ * {@link KeyEvent#KEYCODE_UNKNOWN}.
+ */
+ private int[] mOffScreenNudgeKeyCodes;
+ /**
+ * Intents to launch an activity when the user nudges up, down, left, or right off the edge of
+ * the screen. No activity is launched if the relevant element of this array is null.
+ */
+ private final Intent[] mOffScreenNudgeIntents = new Intent[NUM_DIRECTIONS];
/**
* Possible actions to do after receiving {@link AccessibilityEvent#TYPE_VIEW_SCROLLED}.
@@ -285,7 +388,8 @@ public class RotaryService extends AccessibilityService implements
private long mAfterScrollActionUntil;
/** Whether we're in rotary mode (vs touch mode). */
- private boolean mInRotaryMode;
+ @VisibleForTesting
+ boolean mInRotaryMode;
/**
* Whether we're in direct manipulation mode.
@@ -294,16 +398,33 @@ public class RotaryService extends AccessibilityService implements
* this mode is controlled by the client app, which is responsible for updating the mode by
* calling {@link DirectManipulationHelper#enableDirectManipulationMode} when needed.
*/
- private boolean mInDirectManipulationMode;
+ @VisibleForTesting
+ boolean mInDirectManipulationMode;
/** The {@link SystemClock#uptimeMillis} when the last rotary rotation event occurred. */
private long mLastRotateEventTime;
/**
- * The repeat count of {@link KeyEvent#KEYCODE_DPAD_CENTER}. Use to prevent processing a center
- * button click when the center button is released after a long press.
+ * How many milliseconds the center buttons must be held down before a long-press is triggered.
+ * This doesn't apply to the application window.
*/
- private int mCenterButtonRepeatCount;
+ @VisibleForTesting
+ long mLongPressMs;
+
+ /**
+ * Whether the center button was held down long enough to trigger a long-press. In this case, a
+ * click won't be triggered when the center button is released.
+ */
+ private boolean mLongPressTriggered;
+
+ private final Handler mHandler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(@NonNull Message msg) {
+ if (msg.what == MSG_LONG_PRESS) {
+ handleCenterButtonLongPressEvent();
+ }
+ }
+ };
/**
* A context to use for fetching the {@link WindowManager} and creating the touch overlay or
@@ -344,12 +465,70 @@ public class RotaryService extends AccessibilityService implements
DIRECTION_TO_KEYCODE_MAP = Collections.unmodifiableMap(map);
}
+ private final BroadcastReceiver mHomeButtonReceiver = new BroadcastReceiver() {
+ // Should match the values in PhoneWindowManager.java
+ private static final String SYSTEM_DIALOG_REASON_KEY = "reason";
+ private static final String SYSTEM_DIALOG_REASON_HOME_KEY = "homekey";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String reason = intent.getStringExtra(SYSTEM_DIALOG_REASON_KEY);
+ if (!SYSTEM_DIALOG_REASON_HOME_KEY.equals(reason)) {
+ L.d("Skipping the processing of ACTION_CLOSE_SYSTEM_DIALOGS broadcast event due "
+ + "to reason: " + reason);
+ return;
+ }
+
+ // Trigger a back action in order to exit direct manipulation mode.
+ if (mInDirectManipulationMode) {
+ handleBackButtonEvent(ACTION_DOWN);
+ handleBackButtonEvent(ACTION_UP);
+ }
+
+ List<AccessibilityWindowInfo> windows = getWindows();
+ for (AccessibilityWindowInfo window : windows) {
+ if (window == null) {
+ continue;
+ }
+
+ if (mInRotaryMode && mNavigator.isMainApplicationWindow(window)) {
+ // Post this in a handler so that there is no race condition between app
+ // transitions and restoration of focus.
+ getMainThreadHandler().post(() -> {
+ AccessibilityNodeInfo rootView = window.getRoot();
+ if (rootView == null) {
+ L.e("Root view in application window no longer exists");
+ return;
+ }
+ boolean result = restoreDefaultFocusInRoot(rootView);
+ if (!result) {
+ L.e("Failed to focus the default element in the application window");
+ }
+ Utils.recycleNode(rootView);
+ });
+ } else {
+ // Post this in a handler so that there is no race condition between app
+ // transitions and restoration of focus.
+ getMainThreadHandler().post(() -> {
+ boolean result = clearFocusInWindow(window);
+ if (!result) {
+ L.e("Failed to clear the focus in window: " + window);
+ }
+ });
+ }
+ }
+ Utils.recycleWindows(windows);
+ }
+ };
+
private Car mCar;
private CarInputManager mCarInputManager;
private InputManager mInputManager;
- /** Package name of foreground app. */
- private CharSequence mForegroundApp;
+ /** Component name of foreground activity. */
+ @VisibleForTesting
+ @Nullable
+ ComponentName mForegroundActivity;
private WindowManager mWindowManager;
@@ -366,6 +545,26 @@ public class RotaryService extends AccessibilityService implements
/** Expiration time for {@link #mPendingFocusedNode} in {@link SystemClock#uptimeMillis}. */
private long mPendingFocusedExpirationTime;
+ private final BroadcastReceiver mAppInstallUninstallReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String packageName = intent.getData().getSchemeSpecificPart();
+ if (TextUtils.isEmpty(packageName)) {
+ L.e("System sent an empty app install/uninstall broadcast");
+ return;
+ }
+ if (mNavigator == null) {
+ L.v("mNavigator is not initialized");
+ return;
+ }
+ if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {
+ mNavigator.clearHostApp(packageName);
+ } else {
+ mNavigator.initHostApp(getPackageManager());
+ }
+ }
+ };
+
@Override
public void onCreate() {
super.onCreate();
@@ -389,30 +588,59 @@ public class RotaryService extends AccessibilityService implements
mAfterScrollTimeoutMs = res.getInteger(R.integer.after_scroll_timeout_ms);
mNavigator = new Navigator(displayWidth, displayHeight, hunLeft, hunRight, showHunOnBottom);
+ mNavigator.initHostApp(getPackageManager());
mPrefs = createDeviceProtectedStorageContext().getSharedPreferences(SHARED_PREFS,
Context.MODE_PRIVATE);
mUserManager = getSystemService(UserManager.class);
- // Verify that the component names for default_touch_input_method and rotary_input_method
- // are valid. If mTouchInputMethod or mRotaryInputMethod is empty, IMEs should not switch
- // because RotaryService won't be able to switch them back.
- String defaultTouchInputMethod = res.getString(R.string.default_touch_input_method);
- if (isValidIme(defaultTouchInputMethod)) {
- mTouchInputMethod = mPrefs.getString(
- TOUCH_INPUT_METHOD_PREFIX + mUserManager.getUserName(), defaultTouchInputMethod);
- // Set the DEFAULT_INPUT_METHOD in case Android defaults to the rotary_input_method.
+ mRotaryInputMethod = res.getString(R.string.rotary_input_method);
+ mDefaultTouchInputMethod = res.getString(R.string.default_touch_input_method);
+ mTouchInputMethod = mPrefs.getString(TOUCH_INPUT_METHOD_PREFIX + mUserManager.getUserName(),
+ mDefaultTouchInputMethod);
+ if (mRotaryInputMethod != null
+ && mRotaryInputMethod.equals(getCurrentIme())
+ && isValidIme(mTouchInputMethod)) {
+ // Switch from the rotary IME to the touch IME in case Android defaults to the rotary
+ // IME.
// TODO(b/169423887): Figure out how to configure the default IME through Android
// without needing to do this.
- Settings.Secure.putString(
- getContentResolver(), DEFAULT_INPUT_METHOD, mTouchInputMethod);
- }
- String rotaryInputMethod = res.getString(R.string.rotary_input_method);
- if (isValidIme(rotaryInputMethod)) {
- mRotaryInputMethod = rotaryInputMethod;
+ setCurrentIme(mTouchInputMethod);
+
}
mAfterFocusTimeoutMs = res.getInteger(R.integer.after_focus_timeout_ms);
+
+ mLongPressMs = res.getInteger(R.integer.long_press_ms);
+ if (mLongPressMs == 0) {
+ mLongPressMs = ViewConfiguration.getLongPressTimeout();
+ }
+
+ mOffScreenNudgeGlobalActions = res.getIntArray(R.array.off_screen_nudge_global_actions);
+ mOffScreenNudgeKeyCodes = res.getIntArray(R.array.off_screen_nudge_key_codes);
+ String[] intentUrls = res.getStringArray(R.array.off_screen_nudge_intents);
+ for (int i = 0; i < NUM_DIRECTIONS; i++) {
+ String intentUrl = intentUrls[i];
+ if (intentUrl == null || intentUrl.isEmpty()) {
+ continue;
+ }
+ try {
+ mOffScreenNudgeIntents[i] = Intent.parseUri(intentUrl, Intent.URI_INTENT_SCHEME);
+ } catch (URISyntaxException e) {
+ L.w("Invalid off-screen nudge intent: " + intentUrl);
+ }
+ }
+
+ getWindowContext().registerReceiver(mHomeButtonReceiver,
+ new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
+
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_PACKAGE_ADDED);
+ filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+ filter.addAction(Intent.ACTION_PACKAGE_REPLACED);
+ filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+ filter.addDataScheme("package");
+ registerReceiver(mAppInstallUninstallReceiver, filter);
}
/**
@@ -476,6 +704,9 @@ public class RotaryService extends AccessibilityService implements
@Override
public void onDestroy() {
+ unregisterReceiver(mAppInstallUninstallReceiver);
+ getWindowContext().unregisterReceiver(mHomeButtonReceiver);
+
unregisterInputMethodObserver();
if (mCarInputManager != null) {
mCarInputManager.releaseInputEventCapture(CarInputManager.TARGET_DISPLAY_TYPE_MAIN);
@@ -483,6 +714,11 @@ public class RotaryService extends AccessibilityService implements
if (mCar != null) {
mCar.disconnect();
}
+
+ // Reset to touch IME if the current IME is rotary IME.
+ mInRotaryMode = false;
+ updateIme();
+
super.onDestroy();
}
@@ -517,8 +753,17 @@ public class RotaryService extends AccessibilityService implements
break;
}
case TYPE_WINDOW_STATE_CHANGED: {
- CharSequence packageName = event.getPackageName();
- onForegroundAppChanged(packageName);
+ if (source != null) {
+ AccessibilityWindowInfo window = source.getWindow();
+ if (window != null) {
+ if (window.getType() == TYPE_APPLICATION
+ && window.getDisplayId() == DEFAULT_DISPLAY) {
+ onForegroundActivityChanged(source, event.getPackageName(),
+ event.getClassName());
+ }
+ window.recycle();
+ }
+ }
break;
}
case TYPE_WINDOWS_CHANGED: {
@@ -641,11 +886,9 @@ public class RotaryService extends AccessibilityService implements
}
private void onTouchEvent() {
- if (!mInRotaryMode) {
- return;
- }
-
- // Enter touch mode once the user touches the screen.
+ // The user touched the screen, so exit rotary mode. Do this even if mInRotaryMode is
+ // already false because this service might have crashed causing mInRotaryMode to be reset
+ // without a corresponding change to the IME.
setInRotaryMode(false);
// Set mFocusedNode to null when user uses touch.
@@ -661,15 +904,13 @@ public class RotaryService extends AccessibilityService implements
if (mInputMethodObserver != null) {
throw new IllegalStateException("Input method observer already registered");
}
- ContentResolver contentResolver = getContentResolver();
mInputMethodObserver = new ContentObserver(new Handler(Looper.myLooper())) {
@Override
public void onChange(boolean selfChange) {
// Either the user switched input methods or we did. In the former case, update
// mTouchInputMethod and save it so we can switch back after switching to the rotary
// input method.
- String inputMethod =
- Settings.Secure.getString(contentResolver, DEFAULT_INPUT_METHOD);
+ String inputMethod = getCurrentIme();
if (inputMethod != null && !inputMethod.equals(mRotaryInputMethod)) {
mTouchInputMethod = inputMethod;
String userName = mUserManager.getUserName();
@@ -679,7 +920,7 @@ public class RotaryService extends AccessibilityService implements
}
}
};
- contentResolver.registerContentObserver(
+ getContentResolver().registerContentObserver(
Settings.Secure.getUriFor(DEFAULT_INPUT_METHOD),
/* notifyForDescendants= */ false,
mInputMethodObserver);
@@ -739,18 +980,12 @@ public class RotaryService extends AccessibilityService implements
handleNudgeEvent(View.FOCUS_DOWN, action);
return true;
case KeyEvent.KEYCODE_DPAD_CENTER:
- if (isActionDown) {
- mCenterButtonRepeatCount = event.getRepeatCount();
- }
- if (mCenterButtonRepeatCount == 0) {
- handleCenterButtonEvent(action, /* longClick= */ false);
- } else if (mCenterButtonRepeatCount == 1) {
- handleCenterButtonEvent(action, /* longClick= */ true);
+ // Ignore repeat events. We only care about the initial ACTION_DOWN and the final
+ // ACTION_UP events.
+ if (event.getRepeatCount() == 0) {
+ handleCenterButtonEvent(action);
}
return true;
- case KeyEvent.KEYCODE_G:
- handleCenterButtonEvent(action, /* longClick= */ true);
- return true;
case KeyEvent.KEYCODE_BACK:
if (mInDirectManipulationMode) {
handleBackButtonEvent(action);
@@ -776,6 +1011,10 @@ public class RotaryService extends AccessibilityService implements
L.w("Null source node in " + event);
return;
}
+ if (mNavigator.isClientNode(sourceNode)) {
+ L.d("Ignore focused event from the client app " + sourceNode);
+ return;
+ }
// Update mFocusedNode if we're not waiting for focused event caused by performing an
// action.
@@ -918,13 +1157,13 @@ public class RotaryService extends AccessibilityService implements
if (type != null) {
mWindowCache.remove(windowId);
// No longer need to keep track of the node being edited if the IME window was closed.
- if (type.intValue() == TYPE_INPUT_METHOD) {
+ if (type == TYPE_INPUT_METHOD) {
setEditNode(null);
}
// No need to restore the focus if it's an application window. When an application
// window is removed, another window will gain focus shortly and the FocusParkingView
// in that window will restore the focus.
- if (type.intValue() == TYPE_APPLICATION) {
+ if (type == TYPE_APPLICATION) {
return;
}
} else {
@@ -1000,43 +1239,31 @@ public class RotaryService extends AccessibilityService implements
setEditNode(mFocusedNode);
}
- boolean success = restoreDefaultFocus(root);
+ boolean success = restoreDefaultFocusInRoot(root);
if (!success) {
L.d("Failed to restore default focus in " + root);
}
root.recycle();
}
- private boolean restoreDefaultFocus(@NonNull AccessibilityNodeInfo node) {
- AccessibilityNodeInfo fpv = mNavigator.findFocusParkingView(node);
-
+ private boolean restoreDefaultFocusInRoot(@NonNull AccessibilityNodeInfo root) {
+ AccessibilityNodeInfo fpv = mNavigator.findFocusParkingViewInRoot(root);
// Refresh the node to ensure the focused state is up to date. The node came directly from
// the node tree but it could have been cached by the accessibility framework.
fpv = Utils.refreshNode(fpv);
if (fpv == null) {
- L.e("No FocusParkingView in the window containing " + node);
+ L.e("No FocusParkingView in root " + root);
} else if (Utils.isCarUiFocusParkingView(fpv)
&& fpv.performAction(ACTION_RESTORE_DEFAULT_FOCUS)) {
+ L.d("Restored focus successfully in root " + root);
fpv.recycle();
- findFocusedNode(node);
+ updateFocusedNodeAfterPerformingFocusAction(root);
return true;
}
Utils.recycleNode(fpv);
- AccessibilityWindowInfo window = node.getWindow();
- if (window == null) {
- L.e("No window found for the generic FocusParkingView");
- return false;
- }
- AccessibilityNodeInfo root = window.getRoot();
- window.recycle();
- if (root == null) {
- L.w("No root node in " + window);
- return false;
- }
AccessibilityNodeInfo firstFocusable = mNavigator.findFirstFocusableDescendant(root);
- root.recycle();
if (firstFocusable == null) {
L.e("No focusable element in the window containing the generic FocusParkingView");
return false;
@@ -1058,7 +1285,7 @@ public class RotaryService extends AccessibilityService implements
}
/** Handles controller center button event. */
- private void handleCenterButtonEvent(int action, boolean longClick) {
+ private void handleCenterButtonEvent(int action) {
if (!isValidAction(action)) {
return;
}
@@ -1082,10 +1309,12 @@ public class RotaryService extends AccessibilityService implements
return;
}
- // Case 2: the focused node doesn't support rotate directly and it's in application window.
+ // Case 2: the focused node doesn't support rotate directly, it's in application window,
+ // and it's not in the host app.
// We should inject KEYCODE_DPAD_CENTER event (or KEYCODE_ENTER in a WebView), then the
// application will handle the injected event.
- if (isInApplicationWindow(mFocusedNode)) {
+ if (isInApplicationWindow(mFocusedNode) && !mNavigator.isHostNode(mFocusedNode)) {
+ L.d("Inject KeyEvent in application window");
int keyCode = mNavigator.isInWebView(mFocusedNode)
? KeyEvent.KEYCODE_ENTER
: KeyEvent.KEYCODE_DPAD_CENTER;
@@ -1094,23 +1323,41 @@ public class RotaryService extends AccessibilityService implements
return;
}
- // Case 3: the focus node doesn't support rotate directly and it's not in application window
- // (e.g., in system window). We should ignore ACTION_DOWN event, and click or long click
- // the focused node on ACTION_UP event.
+ // Case 3: the focused node doesn't support rotate directly, it's in system window or in
+ // the host app.
+ // We start a timer on the ACTION_DOWN event. If the ACTION_UP event occurs before the
+ // timeout, we perform ACTION_CLICK on the focused node and abort the timer. If the timer
+ // times out before the ACTION_UP event, handleCenterButtonLongPressEvent() will perform
+ // ACTION_LONG_CLICK on the focused node and we'll ignore the subsequent ACTION_UP event.
if (action == ACTION_DOWN) {
+ mLongPressTriggered = false;
+ mHandler.removeMessages(MSG_LONG_PRESS);
+ mHandler.sendEmptyMessageDelayed(MSG_LONG_PRESS, mLongPressMs);
return;
}
- boolean result = mFocusedNode.performAction(longClick ? ACTION_LONG_CLICK : ACTION_CLICK);
- if (!result) {
- L.w("Failed to perform " + (longClick ? "ACTION_LONG_CLICK" : "ACTION_CLICK")
- + " on " + mFocusedNode);
+ if (mLongPressTriggered) {
+ mLongPressTriggered = false;
+ return;
}
- if (!longClick) {
- setIgnoreViewClickedNode(mFocusedNode);
+ mHandler.removeMessages(MSG_LONG_PRESS);
+ boolean success = mFocusedNode.performAction(ACTION_CLICK);
+ L.d((success ? "Succeeded in performing" : "Failed to perform")
+ + " ACTION_CLICK on " + mFocusedNode);
+ setIgnoreViewClickedNode(mFocusedNode);
+ }
+
+ /** Handles controller center button long-press events. */
+ private void handleCenterButtonLongPressEvent() {
+ mLongPressTriggered = true;
+ if (initFocus()) {
+ return;
}
+ boolean success = mFocusedNode.performAction(ACTION_LONG_CLICK);
+ L.d((success ? "Succeeded in performing" : "Failed to perform")
+ + " ACTION_LONG_CLICK on " + mFocusedNode);
}
- private void handleNudgeEvent(int direction, int action) {
+ private void handleNudgeEvent(@View.FocusRealDirection int direction, int action) {
if (!isValidAction(action)) {
return;
}
@@ -1152,42 +1399,35 @@ public class RotaryService extends AccessibilityService implements
// If the focused node is not in direct manipulation mode, try to move the focus to another
// node.
- boolean success = nudgeTo(windows, direction);
+ nudgeTo(windows, direction);
Utils.recycleWindows(windows);
-
- // If the user is nudging out of the IME to the node being edited, we no longer need
- // to keep track of the node being edited.
- if (success) {
- mEditNode = Utils.refreshNode(mEditNode);
- if (mEditNode != null && mEditNode.isFocused()) {
- setEditNode(null);
- }
- }
}
- private boolean nudgeTo(@NonNull List<AccessibilityWindowInfo> windows, int direction) {
+ @VisibleForTesting
+ void nudgeTo(@NonNull List<AccessibilityWindowInfo> windows,
+ @View.FocusRealDirection int direction) {
// If the HUN is in the nudge direction, nudge to it.
boolean hunFocusResult = focusHunsWindow(windows, direction);
if (hunFocusResult) {
L.d("Nudge to HUN successful");
- return true;
+ return;
}
// Try to move the focus to the shortcut node.
if (mFocusArea == null) {
L.e("mFocusArea shouldn't be null");
- return false;
+ return;
}
Bundle arguments = new Bundle();
arguments.putInt(NUDGE_DIRECTION, direction);
if (mFocusArea.performAction(ACTION_NUDGE_SHORTCUT, arguments)) {
L.d("Nudge to shortcut view");
- AccessibilityNodeInfo root = Utils.getRoot(mFocusArea);
+ AccessibilityNodeInfo root = mNavigator.getRoot(mFocusArea);
if (root != null) {
- findFocusedNode(root);
+ updateFocusedNodeAfterPerformingFocusAction(root);
root.recycle();
}
- return true;
+ return;
}
// No shortcut node, so move the focus in the given direction.
@@ -1196,22 +1436,59 @@ public class RotaryService extends AccessibilityService implements
arguments.putInt(NUDGE_DIRECTION, direction);
if (mFocusArea.performAction(ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA, arguments)) {
L.d("Nudge to user specified FocusArea");
- AccessibilityNodeInfo root = Utils.getRoot(mFocusArea);
+ AccessibilityNodeInfo root = mNavigator.getRoot(mFocusArea);
if (root != null) {
- findFocusedNode(root);
+ updateFocusedNodeAfterPerformingFocusAction(root);
root.recycle();
}
- return true;
+ return;
}
// No specified FocusArea or cached FocusArea in the direction, so mFocusArea doesn't know
// what FocusArea to nudge to. In this case, we'll find a target FocusArea using geometry.
AccessibilityNodeInfo targetFocusArea =
mNavigator.findNudgeTargetFocusArea(windows, mFocusedNode, mFocusArea, direction);
+
+ // If the user is nudging off the edge of the screen, execute the app-specific or app-
+ // agnostic off-screen nudge action, if either are specified. The former take precedence
+ // over the latter.
if (targetFocusArea == null) {
- L.d("Failed to find a target FocusArea for the nudge");
- return false;
+ if (handleAppSpecificOffScreenNudge(direction)) {
+ return;
+ }
+ if (handleAppAgnosticOffScreenNudge(direction)) {
+ return;
+ }
+ L.d("Off-screen nudge ignored");
+ return;
}
+
+ // If the user is nudging out of the IME, set mFocusedNode to the node being edited (which
+ // should already be focused) and hide the IME.
+ if (mEditNode != null && mFocusArea.getWindowId() != targetFocusArea.getWindowId()) {
+ AccessibilityWindowInfo fromWindow = mFocusArea.getWindow();
+ if (fromWindow != null && fromWindow.getType() == TYPE_INPUT_METHOD) {
+ setFocusedNode(mEditNode);
+ L.d("Returned to node being edited");
+ // Ask the FocusParkingView to hide the IME.
+ AccessibilityNodeInfo fpv = mNavigator.findFocusParkingView(mEditNode);
+ if (fpv != null) {
+ if (!fpv.performAction(ACTION_HIDE_IME)) {
+ L.w("Failed to close IME");
+ }
+ fpv.recycle();
+ }
+ setEditNode(null);
+ Utils.recycleWindow(fromWindow);
+ targetFocusArea.recycle();
+ return;
+ }
+ Utils.recycleWindow(fromWindow);
+ }
+
+ // targetFocusArea is an explicit FocusArea (i.e., an instance of the FocusArea class), so
+ // perform ACTION_FOCUS on it. The FocusArea will handle this by focusing one of its
+ // descendants.
if (Utils.isFocusArea(targetFocusArea)) {
arguments.clear();
arguments.putInt(NUDGE_DIRECTION, direction);
@@ -1219,16 +1496,141 @@ public class RotaryService extends AccessibilityService implements
L.d("Nudging to the nearest FocusArea "
+ (success ? "succeeded" : "failed: " + targetFocusArea));
targetFocusArea.recycle();
- return success;
+ return;
}
// targetFocusArea is an implicit FocusArea (i.e., the root node of a window without any
// FocusAreas), so restore the focus in it.
- boolean success = restoreDefaultFocus(targetFocusArea);
+ boolean success = restoreDefaultFocusInRoot(targetFocusArea);
L.d("Nudging to the nearest implicit focus area "
+ (success ? "succeeded" : "failed: " + targetFocusArea));
targetFocusArea.recycle();
- return success;
+ }
+
+ /**
+ * Executes the app-specific custom nudge action for the given {@code direction} specified in
+ * {@link #mForegroundActivity}'s metadata, if any, by: <ul>
+ * <li>performing the specified global action,
+ * <li>injecting {@code ACTION_DOWN} and {@code ACTION_UP} events with the
+ * specified key code, or
+ * <li>starting an activity with the specified intent.
+ * </ul>
+ * Returns whether a custom nudge action was performed.
+ */
+ private boolean handleAppSpecificOffScreenNudge(@View.FocusRealDirection int direction) {
+ Bundle metaData = getForegroundActivityMetaData();
+ if (metaData == null) {
+ L.w("Failed to get metadata for " + mForegroundActivity);
+ return false;
+ }
+ String directionString = DIRECTION_TO_STRING.get(direction);
+ int globalAction = metaData.getInt(
+ String.format(OFF_SCREEN_NUDGE_GLOBAL_ACTION_FORMAT, directionString),
+ INVALID_GLOBAL_ACTION);
+ if (globalAction != INVALID_GLOBAL_ACTION) {
+ L.d("App-specific off-screen nudge: " + globalActionToString(globalAction));
+ performGlobalAction(globalAction);
+ return true;
+ }
+ int keyCode = metaData.getInt(
+ String.format(OFF_SCREEN_NUDGE_KEY_CODE_FORMAT, directionString), KEYCODE_UNKNOWN);
+ if (keyCode != KEYCODE_UNKNOWN) {
+ L.d("App-specific off-screen nudge: " + KeyEvent.keyCodeToString(keyCode));
+ injectKeyEvent(keyCode, ACTION_DOWN);
+ injectKeyEvent(keyCode, ACTION_UP);
+ return true;
+ }
+ String intentString = metaData.getString(
+ String.format(OFF_SCREEN_NUDGE_INTENT_FORMAT, directionString), null);
+ if (intentString == null) {
+ return false;
+ }
+ Intent intent;
+ try {
+ intent = Intent.parseUri(intentString, Intent.URI_INTENT_SCHEME);
+ } catch (URISyntaxException e) {
+ L.w("Failed to parse app-specific off-screen nudge intent: " + intentString);
+ return false;
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ List<ResolveInfo> activities =
+ getPackageManager().queryIntentActivities(intent, /* flags= */ 0);
+ if (activities.isEmpty()) {
+ L.w("No activities for app-specific off-screen nudge: " + intent);
+ return false;
+ }
+ L.d("App-specific off-screen nudge: " + intent);
+ startActivity(intent);
+ return true;
+ }
+
+ /**
+ * Executes the app-agnostic custom nudge action for the given {@code direction}, if any. This
+ * method is equivalent to {@link #handleAppSpecificOffScreenNudge} but for global actions
+ * rather than app-specific ones.
+ */
+ private boolean handleAppAgnosticOffScreenNudge(@View.FocusRealDirection int direction) {
+ int directionIndex = DIRECTION_TO_INDEX.get(direction);
+ int globalAction = mOffScreenNudgeGlobalActions[directionIndex];
+ if (globalAction != INVALID_GLOBAL_ACTION) {
+ L.d("App-agnostic off-screen nudge: " + globalActionToString(globalAction));
+ performGlobalAction(globalAction);
+ return true;
+ }
+ int keyCode = mOffScreenNudgeKeyCodes[directionIndex];
+ if (keyCode != KEYCODE_UNKNOWN) {
+ L.d("App-agnostic off-screen nudge: " + KeyEvent.keyCodeToString(keyCode));
+ injectKeyEvent(keyCode, ACTION_DOWN);
+ injectKeyEvent(keyCode, ACTION_UP);
+ return true;
+ }
+ Intent intent = mOffScreenNudgeIntents[directionIndex];
+ if (intent == null) {
+ return false;
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ PackageManager packageManager = getPackageManager();
+ List<ResolveInfo> activities = packageManager.queryIntentActivities(intent, /* flags= */ 0);
+ if (activities.isEmpty()) {
+ L.w("No activities for app-agnostic off-screen nudge: " + intent);
+ return false;
+ }
+ L.d("App-agnostic off-screen nudge: " + intent);
+ startActivity(intent);
+ return true;
+ }
+
+ @Nullable
+ private Bundle getForegroundActivityMetaData() {
+ // The foreground activity can be null in a cold boot when the user has an active
+ // lockscreen.
+ if (mForegroundActivity == null) {
+ return null;
+ }
+
+ try {
+ ActivityInfo activityInfo = getPackageManager().getActivityInfo(mForegroundActivity,
+ PackageManager.GET_META_DATA);
+ return activityInfo.metaData;
+ } catch (PackageManager.NameNotFoundException e) {
+ return null;
+ }
+ }
+
+ @NonNull
+ private static String globalActionToString(int globalAction) {
+ switch (globalAction) {
+ case GLOBAL_ACTION_BACK:
+ return "GLOBAL_ACTION_BACK";
+ case GLOBAL_ACTION_HOME:
+ return "GLOBAL_ACTION_HOME";
+ case GLOBAL_ACTION_NOTIFICATIONS:
+ return "GLOBAL_ACTION_NOTIFICATIONS";
+ case GLOBAL_ACTION_QUICK_SETTINGS:
+ return "GLOBAL_ACTION_QUICK_SETTINGS";
+ default:
+ return String.format("global action %d", globalAction);
+ }
}
private void handleRotaryEvent(RotaryEvent rotaryEvent) {
@@ -1324,11 +1726,20 @@ public class RotaryService extends AccessibilityService implements
}
}
- private void onForegroundAppChanged(CharSequence packageName) {
- if (TextUtils.equals(mForegroundApp, packageName)) {
+ private void onForegroundActivityChanged(@NonNull AccessibilityNodeInfo root,
+ CharSequence packageName, CharSequence className) {
+ // If the foreground app is a client app, store its package name.
+ AccessibilityNodeInfo surfaceView = mNavigator.findSurfaceViewInRoot(root);
+ if (surfaceView != null) {
+ mNavigator.addClientApp(surfaceView.getPackageName());
+ surfaceView.recycle();
+ }
+
+ ComponentName newActivity = new ComponentName(packageName.toString(), className.toString());
+ if (newActivity.equals(mForegroundActivity)) {
return;
}
- mForegroundApp = packageName;
+ mForegroundActivity = newActivity;
if (mInDirectManipulationMode) {
L.d("Exit direct manipulation mode because the foreground app has changed");
mInDirectManipulationMode = false;
@@ -1360,7 +1771,8 @@ public class RotaryService extends AccessibilityService implements
}
/** Returns whether the given {@code node} is in the application window. */
- private static boolean isInApplicationWindow(@NonNull AccessibilityNodeInfo node) {
+ @VisibleForTesting
+ boolean isInApplicationWindow(@NonNull AccessibilityNodeInfo node) {
AccessibilityWindowInfo window = node.getWindow();
if (window == null) {
L.w("Failed to get window of " + node);
@@ -1382,9 +1794,9 @@ public class RotaryService extends AccessibilityService implements
+ "in view tree.");
return;
}
- if (!mFocusedNode.isFocused()) {
- L.w("Failed to enter direct manipulation mode because mFocusedNode is no longer "
- + "focused.");
+ if (!Utils.hasFocus(mFocusedNode)) {
+ L.w("Failed to enter direct manipulation mode because mFocusedNode no longer "
+ + "has focus.");
return;
}
}
@@ -1477,20 +1889,21 @@ public class RotaryService extends AccessibilityService implements
}
}
- private boolean injectKeyEventForDirection(int direction, int action) {
+ private void injectKeyEventForDirection(@View.FocusRealDirection int direction, int action) {
Integer keyCode = DIRECTION_TO_KEYCODE_MAP.get(direction);
if (keyCode == null) {
throw new IllegalArgumentException("direction must be one of "
+ "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT}.");
}
- return injectKeyEvent(keyCode, action);
+ injectKeyEvent(keyCode, action);
}
- private boolean injectKeyEvent(int keyCode, int action) {
+ @VisibleForTesting
+ void injectKeyEvent(int keyCode, int action) {
long upTime = SystemClock.uptimeMillis();
KeyEvent keyEvent = new KeyEvent(
/* downTime= */ upTime, /* eventTime= */ upTime, action, keyCode, /* repeat= */ 0);
- return mInputManager.injectInputEvent(keyEvent, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+ mInputManager.injectInputEvent(keyEvent, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
}
/**
@@ -1511,7 +1924,7 @@ public class RotaryService extends AccessibilityService implements
* <li>If {@link #mFocusedNode} isn't null and represents a view that still exists, does
* nothing. The event isn't consumed in this case. This is the normal case.
* <li>If there is a non-FocusParkingView focused in any window, set mFocusedNode to that
- * view.
+ * view. The event isn't consumed in this case.
* <li>If {@link #mLastTouchedNode} isn't null and represents a view that still exists,
* focuses it. The event is consumed in this case. This happens when the user switches
* from touch to rotary.
@@ -1521,7 +1934,8 @@ public class RotaryService extends AccessibilityService implements
* @return whether the event was consumed by this method. When {@code false},
* {@link #mFocusedNode} is guaranteed to not be {@code null}.
*/
- private boolean initFocus() {
+ @VisibleForTesting
+ boolean initFocus() {
List<AccessibilityWindowInfo> windows = getWindows();
boolean consumed = initFocus(windows, INVALID_NUDGE_DIRECTION);
Utils.recycleWindows(windows);
@@ -1538,7 +1952,8 @@ public class RotaryService extends AccessibilityService implements
* @return whether the event was consumed by this method. When {@code false},
* {@link #mFocusedNode} is guaranteed to not be {@code null}.
*/
- private boolean initFocus(@NonNull List<AccessibilityWindowInfo> windows, int direction) {
+ private boolean initFocus(@NonNull List<AccessibilityWindowInfo> windows,
+ @View.FocusRealDirection int direction) {
boolean prevInRotaryMode = mInRotaryMode;
refreshSavedNodes();
setInRotaryMode(true);
@@ -1558,7 +1973,7 @@ public class RotaryService extends AccessibilityService implements
// If we were not in rotary mode before and we can focus the HUNs window for the given
// nudge, focus the window and ensure that there is no previously touched node.
- if (!prevInRotaryMode && windows != null && focusHunsWindow(windows, direction)) {
+ if (!prevInRotaryMode && focusHunsWindow(windows, direction)) {
setLastTouchedNode(null);
return true;
}
@@ -1567,9 +1982,9 @@ public class RotaryService extends AccessibilityService implements
for (AccessibilityWindowInfo window : windows) {
AccessibilityNodeInfo root = window.getRoot();
if (root != null) {
- AccessibilityNodeInfo focusedNode = root.findFocus(FOCUS_INPUT);
+ AccessibilityNodeInfo focusedNode = mNavigator.findFocusedNodeInRoot(root);
root.recycle();
- if (focusedNode != null && !Utils.isFocusParkingView(focusedNode)) {
+ if (focusedNode != null) {
setFocusedNode(focusedNode);
focusedNode.recycle();
return false;
@@ -1580,9 +1995,10 @@ public class RotaryService extends AccessibilityService implements
if (mLastTouchedNode != null && focusLastTouchedNode()) {
return true;
}
+
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root != null) {
- restoreDefaultFocus(root);
+ restoreDefaultFocusInRoot(root);
Utils.recycleNode(root);
}
return true;
@@ -1634,18 +2050,53 @@ public class RotaryService extends AccessibilityService implements
L.e("Don't call clearFocusInCurrentWindow() when mFocusedNode is null");
return false;
}
- AccessibilityNodeInfo fpv = mNavigator.findFocusParkingView(mFocusedNode);
+ AccessibilityNodeInfo root = mNavigator.getRoot(mFocusedNode);
+ boolean result = clearFocusInRoot(root);
+ root.recycle();
+ return result;
+ }
+
+ /**
+ * Clears the rotary focus in the given {@code window}.
+ *
+ * @return whether the FocusParkingView was focused successfully
+ */
+ private boolean clearFocusInWindow(@NonNull AccessibilityWindowInfo window) {
+ AccessibilityNodeInfo root = window.getRoot();
+ if (root == null) {
+ L.e("No root node in the window " + window);
+ return false;
+ }
+
+ boolean success = clearFocusInRoot(root);
+ root.recycle();
+ return success;
+ }
+
+ /**
+ * Clears the rotary focus in the node tree rooted at {@code root}.
+ * <p>
+ * If we really clear focus in a window, Android will re-focus a view in that window
+ * automatically. To avoid that we don't really clear the focus. Instead, we "park" the focus on
+ * a FocusParkingView in the given window. FocusParkingView is transparent no matter whether
+ * it's focused or not, so it's invisible to the user.
+ *
+ * @return whether the FocusParkingView was focused successfully
+ */
+ private boolean clearFocusInRoot(@NonNull AccessibilityNodeInfo root) {
+ AccessibilityNodeInfo fpv = mNavigator.findFocusParkingViewInRoot(root);
// Refresh the node to ensure the focused state is up to date. The node came directly from
// the node tree but it could have been cached by the accessibility framework.
fpv = Utils.refreshNode(fpv);
if (fpv == null) {
- L.e("No FocusParkingView in the window that contains " + mFocusedNode);
+ L.e("No FocusParkingView in the window that contains " + root);
return false;
}
if (fpv.isFocused()) {
L.d("FocusParkingView is already focused " + fpv);
+ fpv.recycle();
return true;
}
boolean result = performFocusAction(fpv);
@@ -1656,7 +2107,8 @@ public class RotaryService extends AccessibilityService implements
return result;
}
- private boolean focusHunsWindow(@NonNull List<AccessibilityWindowInfo> windows, int direction) {
+ private boolean focusHunsWindow(@NonNull List<AccessibilityWindowInfo> windows,
+ @View.FocusRealDirection int direction) {
if (direction != mHunNudgeDirection) {
return false;
}
@@ -1673,7 +2125,7 @@ public class RotaryService extends AccessibilityService implements
return false;
}
- boolean success = restoreDefaultFocus(hunRoot);
+ boolean success = restoreDefaultFocusInRoot(hunRoot);
hunRoot.recycle();
L.d("HUN window focus " + (success ? "successful" : "failed"));
return success;
@@ -1699,7 +2151,8 @@ public class RotaryService extends AccessibilityService implements
/**
* Sets {@link #mFocusedNode} to a copy of the given node, and clears {@link #mLastTouchedNode}.
*/
- private void setFocusedNode(@Nullable AccessibilityNodeInfo focusedNode) {
+ @VisibleForTesting
+ void setFocusedNode(@Nullable AccessibilityNodeInfo focusedNode) {
// Android doesn't clear focus automatically when focus is set in another window, so we need
// to do it explicitly.
maybeClearFocusInCurrentWindow(focusedNode);
@@ -1750,6 +2203,7 @@ public class RotaryService extends AccessibilityService implements
private void setPendingFocusedNode(@Nullable AccessibilityNodeInfo node) {
Utils.recycleNode(mPendingFocusedNode);
mPendingFocusedNode = copyNode(node);
+ L.d("mPendingFocusedNode set to " + mPendingFocusedNode);
mPendingFocusedExpirationTime = SystemClock.uptimeMillis() + mAfterFocusTimeoutMs;
}
@@ -1796,7 +2250,8 @@ public class RotaryService extends AccessibilityService implements
/**
* Sets {@link #mLastTouchedNode} to a copy of the given node, and clears {@link #mFocusedNode}.
*/
- private void setLastTouchedNode(@Nullable AccessibilityNodeInfo lastTouchedNode) {
+ @VisibleForTesting
+ void setLastTouchedNode(@Nullable AccessibilityNodeInfo lastTouchedNode) {
setLastTouchedNodeInternal(lastTouchedNode);
if (mLastTouchedNode != null && mFocusedNode != null) {
setFocusedNodeInternal(null);
@@ -1825,14 +2280,15 @@ public class RotaryService extends AccessibilityService implements
}
private void setInRotaryMode(boolean inRotaryMode) {
- if (inRotaryMode == mInRotaryMode) {
- return;
- }
mInRotaryMode = inRotaryMode;
+ if (!mInRotaryMode) {
+ setEditNode(null);
+ }
+ updateIme();
// If we're controlling direct manipulation mode (i.e., the focused node supports rotate
// directly), exit the mode when the user touches the screen.
- if (!inRotaryMode && mInDirectManipulationMode) {
+ if (!mInRotaryMode && mInDirectManipulationMode) {
if (mFocusedNode == null) {
L.e("mFocused is null in direct manipulation mode");
} else if (DirectManipulationHelper.supportRotateDirectly(mFocusedNode)) {
@@ -1846,30 +2302,31 @@ public class RotaryService extends AccessibilityService implements
L.d("The client app should exit direct manipulation mode");
}
}
+ }
- // Update IME.
- if (TextUtils.isEmpty(mRotaryInputMethod)) {
- L.w("No rotary IME configured");
- return;
- }
- if (TextUtils.isEmpty(mTouchInputMethod)) {
- L.w("No touch IME configured");
+ /** Switches to the rotary IME or the touch IME if needed. */
+ private void updateIme() {
+ String newIme = mInRotaryMode ? mRotaryInputMethod : mTouchInputMethod;
+ String oldIme = getCurrentIme();
+ if (oldIme.equals(newIme)) {
+ L.v("No need to switch IME: " + newIme);
return;
}
- if (!inRotaryMode) {
- setEditNode(null);
- }
- // Switch to the rotary IME or the touch IME.
- String newIme = inRotaryMode ? mRotaryInputMethod : mTouchInputMethod;
- if (!isValidIme(newIme)) {
- L.w("Invalid IME: " + newIme);
+ if (mInRotaryMode && !isValidIme(newIme)) {
+ L.w("Rotary IME doesn't exist: " + newIme);
return;
}
+ setCurrentIme(newIme);
+ }
+
+ private String getCurrentIme() {
+ return Settings.Secure.getString(getContentResolver(), DEFAULT_INPUT_METHOD);
+ }
+
+ private void setCurrentIme(String newIme) {
boolean result =
Settings.Secure.putString(getContentResolver(), DEFAULT_INPUT_METHOD, newIme);
- if (!result) {
- L.w("Failed to switch IME: " + newIme);
- }
+ L.successOrFailure("Switching to IME: " + newIme, result);
}
/**
@@ -1941,7 +2398,7 @@ public class RotaryService extends AccessibilityService implements
// If we performed ACTION_FOCUS on a FocusArea, find the descendant that was focused as a
// result.
if (Utils.isFocusArea(targetNode)) {
- if (findFocusedNode(targetNode)) {
+ if (updateFocusedNodeAfterPerformingFocusAction(targetNode)) {
return true;
} else {
L.w("Unable to find focus after performing ACTION_FOCUS on a FocusArea");
@@ -1960,16 +2417,17 @@ public class RotaryService extends AccessibilityService implements
* This method should be called after performing an action which changes the focus where we
* can't predict which node will be focused.
*/
- private boolean findFocusedNode(@NonNull AccessibilityNodeInfo node) {
- AccessibilityNodeInfo foundFocus = node.findFocus(FOCUS_INPUT);
- if (foundFocus == null) {
+ private boolean updateFocusedNodeAfterPerformingFocusAction(
+ @NonNull AccessibilityNodeInfo node) {
+ AccessibilityNodeInfo focusedNode = mNavigator.findFocusedNodeInRoot(node);
+ if (focusedNode == null) {
L.w("Failed to find focused node in " + node);
return false;
}
- L.d("Found focused node " + foundFocus);
- setFocusedNode(foundFocus);
- setPendingFocusedNode(foundFocus);
- foundFocus.recycle();
+ L.d("Found focused node " + focusedNode);
+ setFocusedNode(focusedNode);
+ setPendingFocusedNode(focusedNode);
+ focusedNode.recycle();
return true;
}
@@ -1983,7 +2441,8 @@ public class RotaryService extends AccessibilityService implements
* @param eventTime the {@link SystemClock#uptimeMillis} when the event occurred
* @return the number of "ticks" to rotate
*/
- private int getRotateAcceleration(int count, long eventTime) {
+ @VisibleForTesting
+ int getRotateAcceleration(int count, long eventTime) {
// count is 0 when testing key "C" or "V" is pressed.
if (count <= 0) {
count = 1;
@@ -2005,17 +2464,105 @@ public class RotaryService extends AccessibilityService implements
return mNodeCopier.copy(node);
}
+ /** Sets a NodeCopier instance for testing. */
+ @VisibleForTesting
+ void setNodeCopier(@NonNull NodeCopier nodeCopier) {
+ mNodeCopier = nodeCopier;
+ mNavigator.setNodeCopier(nodeCopier);
+ mWindowCache.setNodeCopier(nodeCopier);
+ }
+
/**
- * Checks if the {@code componentName} is an enabled input method.
- * The string should be in the format {@code "PackageName/.ClassName"}.
- * Example: {@code "com.android.inputmethod.latin/.CarLatinIME"}.
+ * Checks if the {@code componentName} is an enabled input method or a disabled system input
+ * method. The string should be in the format {@code "package.name/.ClassName"}, e.g. {@code
+ * "com.android.inputmethod.latin/.CarLatinIME"}. Disabled system input methods are considered
+ * valid because switching back to the touch IME should occur even if it's disabled and because
+ * the rotary IME may be disabled so that it doesn't get used for touch.
*/
private boolean isValidIme(String componentName) {
if (TextUtils.isEmpty(componentName)) {
return false;
}
- String enabledInputMethods = Settings.Secure.getString(
- getContentResolver(), Settings.Secure.ENABLED_INPUT_METHODS);
- return enabledInputMethods != null && enabledInputMethods.contains(componentName);
+ return imeSettingContains(ENABLED_INPUT_METHODS, componentName)
+ || imeSettingContains(DISABLED_SYSTEM_INPUT_METHODS, componentName);
+ }
+
+ /**
+ * Fetches the secure setting {@code settingName} containing a colon-separated list of IMEs with
+ * their subtypes and returns whether {@code componentName} is one of the IMEs.
+ */
+ private boolean imeSettingContains(@NonNull String settingName, @NonNull String componentName) {
+ String colonSeparatedComponentNamesWithSubtypes =
+ Settings.Secure.getString(getContentResolver(), settingName);
+ if (colonSeparatedComponentNamesWithSubtypes == null) {
+ return false;
+ }
+ return Arrays.stream(colonSeparatedComponentNamesWithSubtypes.split(":"))
+ .map(componentNameWithSubtypes -> componentNameWithSubtypes.split(";"))
+ .anyMatch(componentNameAndSubtypes -> componentNameAndSubtypes.length >= 1
+ && componentNameAndSubtypes[0].equals(componentName));
+ }
+
+ @VisibleForTesting
+ AccessibilityNodeInfo getFocusedNode() {
+ return mFocusedNode;
+ }
+
+ @VisibleForTesting
+ void setNavigator(@NonNull Navigator navigator) {
+ mNavigator = navigator;
+ }
+
+ @VisibleForTesting
+ void setInputManager(@NonNull InputManager inputManager) {
+ mInputManager = inputManager;
+ }
+
+ @Override
+ protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
+ long uptimeMillis = SystemClock.uptimeMillis();
+ writer.println("rotationAcceleration2x: " + mRotationAcceleration2xMs
+ + " ms, 3x: " + mRotationAcceleration3xMs + " ms");
+ writer.println("focusedNode: " + mFocusedNode);
+ writer.println("editNode: " + mEditNode);
+ writer.println("focusArea: " + mFocusArea);
+ writer.println("lastTouchedNode: " + mLastTouchedNode);
+ writer.println("ignoreViewClicked: " + mIgnoreViewClickedMs + "ms");
+ writer.println("ignoreViewClickedNode: " + mIgnoreViewClickedNode
+ + ", time: " + (mLastViewClickedTime - uptimeMillis));
+ writer.println("rotaryInputMethod: " + mRotaryInputMethod);
+ writer.println("defaultTouchInputMethod: " + mDefaultTouchInputMethod);
+ writer.println("touchInputMethod: " + mTouchInputMethod);
+ writer.println("hunNudgeDirection: " + Navigator.directionToString(mHunNudgeDirection)
+ + ", escape: " + Navigator.directionToString(mHunEscapeNudgeDirection));
+ writer.println("offScreenNudgeGlobalActions: "
+ + Arrays.toString(mOffScreenNudgeGlobalActions));
+ writer.print("offScreenNudgeKeyCodes: [");
+ for (int i = 0; i < mOffScreenNudgeKeyCodes.length; i++) {
+ if (i > 0) {
+ writer.print(", ");
+ }
+ writer.print(KeyEvent.keyCodeToString(mOffScreenNudgeKeyCodes[i]));
+ }
+ writer.println("]");
+ writer.println("offScreenNudgeIntents: " + Arrays.toString(mOffScreenNudgeIntents));
+ writer.println("afterScrollTimeout: " + mAfterScrollTimeoutMs + " ms");
+ writer.println("afterScrollAction: " + mAfterScrollAction
+ + ", until: " + (mAfterScrollActionUntil - uptimeMillis));
+ writer.println("inRotaryMode: " + mInRotaryMode);
+ writer.println("inDirectManipulationMode: " + mInDirectManipulationMode);
+ writer.println("lastRotateEventTime: " + (mLastRotateEventTime - uptimeMillis));
+ writer.println("longPress: " + mLongPressMs + " ms, triggered: " + mLongPressTriggered);
+ writer.println("foregroundActivity: " + (mForegroundActivity == null
+ ? "null" : mForegroundActivity.flattenToShortString()));
+ writer.println("afterFocusTimeout: " + mAfterFocusTimeoutMs + " ms");
+ writer.println("pendingFocusedNode: " + mPendingFocusedNode
+ + ", expiration: " + (mPendingFocusedExpirationTime - uptimeMillis));
+
+ writer.println("navigator:");
+ mNavigator.dump(fd, writer, args);
+
+ writer.println("windowCache:");
+ mWindowCache.dump(fd, writer, args);
}
}
diff --git a/src/com/android/car/rotary/SurfaceViewHelper.java b/src/com/android/car/rotary/SurfaceViewHelper.java
new file mode 100644
index 0000000..1799275
--- /dev/null
+++ b/src/com/android/car/rotary/SurfaceViewHelper.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2021 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.android.car.rotary;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.text.TextUtils;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A helper class to support apps using {@link android.view.SurfaceView} for off-process rendering.
+ * <p>
+ * There are two kinds of apps involved in the off-process rendering process: the client apps and
+ * the host app. A client app holds a {@link android.view.SurfaceView} and delegates its rendering
+ * process to the host app. The host app uses the data provided by the client app to render the app
+ * content in the surface provided by the SurfaceView.
+ * <p>
+ * Although both the client app and the host app have independent <strong>view</strong> hierarchies,
+ * their <strong>node</strong> hierarchies are connected. The node hierarchy of the host app is
+ * embedded into the node hierarchy of the client app. To be more specific, the root node of the
+ * host app is the only child of the SurfaceView node, which is a leaf node of the client app.
+ */
+class SurfaceViewHelper {
+
+ /** The intent action to be used by the host app to bind to the RendererService. */
+ private static final String RENDER_ACTION = "android.car.template.host.RendererService";
+
+ /** Package names of the client apps. */
+ private final Set<CharSequence> mClientApps = new HashSet<>();
+
+ /** Package name of the host app. */
+ @Nullable
+ @VisibleForTesting
+ String mHostApp;
+
+ /** Initializes the package name of the host app. */
+ void initHostApp(@NonNull PackageManager packageManager) {
+ List<ResolveInfo> rendererServices = packageManager.queryIntentServices(
+ new Intent(RENDER_ACTION), PackageManager.GET_RESOLVED_FILTER);
+ if (rendererServices == null || rendererServices.isEmpty()) {
+ L.v("No host app found");
+ return;
+ }
+ mHostApp = rendererServices.get(0).serviceInfo.packageName;
+ L.v("Host app has been initialized: " + mHostApp);
+ }
+
+ /** Clears the package name of the host app if the given {@code packageName} matches. */
+ void clearHostApp(@NonNull String packageName) {
+ if (packageName.equals(mHostApp)) {
+ mHostApp = null;
+ L.v("Host app has been set to null");
+ }
+ }
+
+ /** Adds the package name of the client app. */
+ void addClientApp(@NonNull CharSequence clientAppPackageName) {
+ mClientApps.add(clientAppPackageName);
+ }
+
+ /** Returns whether the given {@code node} represents a view of the host app. */
+ boolean isHostNode(@NonNull AccessibilityNodeInfo node) {
+ return !TextUtils.isEmpty(mHostApp) && mHostApp.equals(node.getPackageName());
+ }
+
+ /** Returns whether the given {@code node} represents a view of the client app. */
+ boolean isClientNode(@NonNull AccessibilityNodeInfo node) {
+ return mClientApps.contains(node.getPackageName());
+ }
+
+ public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
+ writer.println(" hostApp: " + mHostApp);
+ writer.println(" clientApps: " + mClientApps);
+ }
+}
diff --git a/src/com/android/car/rotary/TreeTraverser.java b/src/com/android/car/rotary/TreeTraverser.java
index e1dc001..7e00032 100644
--- a/src/com/android/car/rotary/TreeTraverser.java
+++ b/src/com/android/car/rotary/TreeTraverser.java
@@ -164,7 +164,7 @@ class TreeTraverser {
}
}
- /** Sets a node copier for testing. */
+ /** Sets a NodeCopier instance for testing. */
@VisibleForTesting
void setNodeCopier(@NonNull NodeCopier nodeCopier) {
mNodeCopier = nodeCopier;
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index 23560ad..d3a0181 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -16,6 +16,8 @@
package com.android.car.rotary;
+import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
+
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
@@ -26,6 +28,7 @@ import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLA
import android.graphics.Rect;
import android.os.Bundle;
+import android.view.SurfaceView;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import android.webkit.WebView;
@@ -60,6 +63,8 @@ final class Utils {
"com.android.car.rotary.FocusParkingView";
private static final String WEB_VIEW_CLASS_NAME = WebView.class.getName();
+ @VisibleForTesting
+ static final String SURFACE_VIEW_CLASS_NAME = SurfaceView.class.getName();
private Utils() {
}
@@ -71,6 +76,13 @@ final class Utils {
}
}
+ /** Recycles all specified nodes. */
+ static void recycleNodes(AccessibilityNodeInfo... nodes) {
+ for (AccessibilityNodeInfo node : nodes) {
+ recycleNode(node);
+ }
+ }
+
/** Recycles a list of nodes. */
static void recycleNodes(@Nullable List<AccessibilityNodeInfo> nodes) {
if (nodes != null) {
@@ -115,6 +127,12 @@ final class Utils {
return false;
}
+ // SurfaceView in the client app shouldn't be focused by the rotary controller. See
+ // SurfaceViewHelper for more context.
+ if (isSurfaceView(node)) {
+ return false;
+ }
+
// Check the bounds in the parent rather than the bounds in the screen because the latter
// are always empty for views that are off screen.
Rect bounds = new Rect();
@@ -182,24 +200,18 @@ final class Utils {
}
/**
- * Returns whether the given {@code node} has focus (i.e. the node or one of its descendants is
- * focused).
+ * Searches {@code node} and its descendants for the focused node. Returns whether the focus
+ * was found.
*/
static boolean hasFocus(@NonNull AccessibilityNodeInfo node) {
- if (node.isFocused()) {
- return true;
- }
- for (int i = 0; i < node.getChildCount(); i++) {
- AccessibilityNodeInfo childNode = node.getChild(i);
- if (childNode != null) {
- boolean result = hasFocus(childNode);
- childNode.recycle();
- if (result) {
- return true;
- }
- }
+ AccessibilityNodeInfo foundFocus = node.findFocus(FOCUS_INPUT);
+ if (foundFocus == null) {
+ L.d("Failed to find focused node in " + node);
+ return false;
}
- return false;
+ L.d("Found focused node " + foundFocus);
+ foundFocus.recycle();
+ return true;
}
/**
@@ -209,6 +221,7 @@ final class Utils {
static boolean isFocusParkingView(@NonNull AccessibilityNodeInfo node) {
return isCarUiFocusParkingView(node) || isGenericFocusParkingView(node);
}
+
/** Returns whether the given {@code node} represents a car ui lib {@link FocusParkingView}. */
static boolean isCarUiFocusParkingView(@NonNull AccessibilityNodeInfo node) {
CharSequence className = node.getClassName();
@@ -245,6 +258,12 @@ final class Utils {
return className != null && WEB_VIEW_CLASS_NAME.contentEquals(className);
}
+ /** Returns whether the given {@code node} represents a {@link SurfaceView}. */
+ static boolean isSurfaceView(@NonNull AccessibilityNodeInfo node) {
+ CharSequence className = node.getClassName();
+ return className != null && SURFACE_VIEW_CLASS_NAME.contentEquals(className);
+ }
+
/**
* Returns whether the given node represents a rotary container, as indicated by its content
* description. This includes containers that can be scrolled using the rotary controller as
@@ -373,19 +392,4 @@ final class Utils {
}
return null;
}
-
- /**
- * Returns the root node in the tree containing {@code node}. Returns null if unable to get
- * the root node for any reason. The caller is responsible for recycling the result.
- */
- @Nullable
- static AccessibilityNodeInfo getRoot(@NonNull AccessibilityNodeInfo node) {
- AccessibilityWindowInfo window = node.getWindow();
- if (window == null) {
- return null;
- }
- AccessibilityNodeInfo root = window.getRoot();
- window.recycle();
- return root;
- }
}
diff --git a/src/com/android/car/rotary/WindowCache.java b/src/com/android/car/rotary/WindowCache.java
index 9e21e6b..a421e29 100644
--- a/src/com/android/car/rotary/WindowCache.java
+++ b/src/com/android/car/rotary/WindowCache.java
@@ -16,11 +16,14 @@
package com.android.car.rotary;
import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
@@ -116,4 +119,17 @@ class WindowCache {
private AccessibilityNodeInfo copyNode(@Nullable AccessibilityNodeInfo node) {
return mNodeCopier.copy(node);
}
+
+ void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
+ writer.println(" windowIds: " + mWindowIds);
+ writer.println(" windowTypes:");
+ for (Map.Entry<Integer, Integer> entry : mWindowTypes.entrySet()) {
+ writer.println(" windowId: " + entry.getKey()
+ + ", type: " + AccessibilityWindowInfo.typeToString(entry.getValue()));
+ }
+ writer.println(" focusedNodes:");
+ for (Map.Entry<Integer, AccessibilityNodeInfo> entry : mFocusedNodes.entrySet()) {
+ writer.println(" windowId: " + entry.getKey() + ", node: " + entry.getValue());
+ }
+ }
}
diff --git a/tests/robotests/Android.bp b/tests/robotests/Android.bp
deleted file mode 100644
index 4c3878e..0000000
--- a/tests/robotests/Android.bp
+++ /dev/null
@@ -1,25 +0,0 @@
-//###############################################
-// CarRotaryController Robolectric test target. #
-//###############################################
-package {
- default_applicable_licenses: ["Android-Apache-2.0"],
-}
-
-android_robolectric_test {
- name: "CarRotaryControllerRoboTests",
-
- srcs: ["src/**/*.java"],
-
- java_resource_dirs: ["config"],
-
- // Include the testing libraries
- libs: [
- "android.car",
- "android.test.base.impl",
- ],
- static_libs: [
- "car-ui-lib",
- ],
-
- instrumentation_for: "CarRotaryController",
-}
diff --git a/tests/robotests/AndroidManifest.xml b/tests/robotests/AndroidManifest.xml
deleted file mode 100644
index 9e423c8..0000000
--- a/tests/robotests/AndroidManifest.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- Copyright (C) 2020 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.
--->
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
- package="com.android.car.rotary.robotests">
-</manifest>
diff --git a/tests/robotests/config/robolectric.properties b/tests/robotests/config/robolectric.properties
deleted file mode 100644
index 8f91c41..0000000
--- a/tests/robotests/config/robolectric.properties
+++ /dev/null
@@ -1,16 +0,0 @@
-#
-# Copyright (C) 2020 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.
-#
-sdk=NEWEST_SDK
diff --git a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
deleted file mode 100644
index 83e06a2..0000000
--- a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
+++ /dev/null
@@ -1,801 +0,0 @@
-/*
- * Copyright (C) 2020 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.android.car.rotary;
-
-import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.mockito.Mockito.when;
-
-import android.graphics.Rect;
-import android.view.View;
-import android.view.accessibility.AccessibilityNodeInfo;
-import android.view.accessibility.AccessibilityWindowInfo;
-
-import androidx.annotation.NonNull;
-
-import com.android.car.rotary.Navigator.FindRotateTargetResult;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-
-import java.util.ArrayList;
-
-@RunWith(RobolectricTestRunner.class)
-public class NavigatorTest {
-
- private Rect mHunWindowBounds;
- private NodeBuilder mNodeBuilder;
- private Navigator mNavigator;
-
- @Before
- public void setUp() {
- mHunWindowBounds = new Rect(50, 10, 950, 200);
- mNodeBuilder = new NodeBuilder(new ArrayList<>());
- // The values of displayWidth and displayHeight don't affect the test, so just use 0.
- mNavigator = new Navigator(/* displayWidth= */ 0, /* displayHeight= */ 0,
- mHunWindowBounds.left, mHunWindowBounds.right,/* showHunOnBottom= */ false);
- mNavigator.setNodeCopier(MockNodeCopierProvider.get());
- }
-
- @Test
- public void testSetRootNodeForWindow() {
- AccessibilityWindowInfo window = new WindowBuilder().build();
- AccessibilityNodeInfo root = mNodeBuilder.build();
- setRootNodeForWindow(root, window);
-
- assertThat(window.getRoot()).isSameInstanceAs(root);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * |
- * focusArea
- * / | \
- * / | \
- * button1 button2 button3
- * </pre>
- */
- @Test
- public void testFindRotateTarget() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusArea = mNodeBuilder.setParent(root).setFocusArea().build();
-
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(focusArea).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(focusArea).build();
- AccessibilityNodeInfo button3 = mNodeBuilder.setParent(focusArea).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(null);
-
- // Rotate once, the focus should move from button1 to button2.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
-
- // Rotate twice, the focus should move from button1 to button3.
- target = mNavigator.findRotateTarget(button1, direction, 2);
- assertThat(target.node).isSameInstanceAs(button3);
- assertThat(target.advancedCount).isEqualTo(2);
-
- // Rotate 3 times and exceed the boundary, the focus should stay at the boundary.
- target = mNavigator.findRotateTarget(button1, direction, 3);
- assertThat(target.node).isSameInstanceAs(button3);
- assertThat(target.advancedCount).isEqualTo(2);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * focusParkingView focusArea
- * / \
- * / \
- * button1 button2
- * </pre>
- */
- @Test
- public void testFindRotateTargetNoWrapAround() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusParkingView = mNodeBuilder.setParent(root).setFpv().build();
- AccessibilityNodeInfo focusArea = mNodeBuilder.setParent(root).setFocusArea().build();
-
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(focusArea).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(focusArea).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(focusParkingView);
- when(focusParkingView.focusSearch(direction)).thenReturn(button1);
-
- // Rotate at the end of focus area, no wrap-around should happen.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * focusArea genericFocusParkingView
- * / \
- * / \
- * button1 button2
- * </pre>
- */
- @Test
- public void testFindRotateTargetNoWrapAroundWithGenericFpv() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusArea = mNodeBuilder.setParent(root).setFocusArea().build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(focusArea).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(focusArea).build();
-
- AccessibilityNodeInfo focusParkingView = mNodeBuilder.setParent(
- root).setGenericFpv().build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(focusParkingView);
- when(focusParkingView.focusSearch(direction)).thenReturn(button1);
-
- // Rotate at the end of focus area, no wrap-around should happen.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * focusParkingView button1 button2
- * </pre>
- */
- @Test
- public void testFindRotateTargetNoWrapAround2() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusParkingView = mNodeBuilder.setParent(root).setFpv().build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(focusParkingView);
- when(focusParkingView.focusSearch(direction)).thenReturn(button1);
-
- // Rotate at the end of focus area, no wrap-around should happen.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * button1 button2 genericFocusParkingView
- * </pre>
- */
- @Test
- public void testFindRotateTargetNoWrapAround2WithGenericFpv() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo focusParkingView = mNodeBuilder.setParent(
- root).setGenericFpv().build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(focusParkingView);
- when(focusParkingView.focusSearch(direction)).thenReturn(button1);
-
- // Rotate at the end of focus area, no wrap-around should happen.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * button1 invisible button2
- * </pre>
- */
- @Test
- public void testFindRotateTargetDoesNotSkipInvisibleNode() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo invisible = mNodeBuilder
- .setParent(root)
- .setVisibleToUser(false)
- .setBoundsInScreen(new Rect(0, 0, 0, 0))
- .build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(invisible);
- when(invisible.focusSearch(direction)).thenReturn(button2);
-
- // Rotate from button1, it shouldn't skip the invisible view.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(invisible);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * button1 empty button2
- * </pre>
- */
- @Test
- public void testFindRotateTargetSkipNodeThatCannotPerformFocus() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo empty = mNodeBuilder
- .setParent(root)
- .setBoundsInParent(new Rect(0, 0, 0, 0))
- .build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(empty);
- when(empty.focusSearch(direction)).thenReturn(button2);
-
- // Rotate from button1, it should skip the empty view.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(button2);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * button1 scrollable button2
- * recyclerView
- * |
- * non-focusable
- * </pre>
- */
- @Test
- public void testFindRotateTargetReturnScrollableContainer() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo recyclerView = mNodeBuilder
- .setParent(root)
- .setScrollableContainer()
- .setScrollable(true)
- .build();
- AccessibilityNodeInfo nonFocusable = mNodeBuilder
- .setFocusable(false)
- .setParent(recyclerView)
- .build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(recyclerView);
- when(recyclerView.focusSearch(direction)).thenReturn(button2);
-
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(recyclerView);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / | \
- * / | \
- * / | \
- * / | \
- * button1 non-scrollable button2
- * recyclerView
- * </pre>
- */
- @Test
- public void testFindRotateTargetSkipScrollableContainer() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo recyclerView = mNodeBuilder
- .setParent(root)
- .setScrollableContainer()
- .build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(recyclerView);
- when(recyclerView.focusSearch(direction)).thenReturn(button2);
-
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(button2);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * focusParkingView scrollable
- * recyclerView
- * / \
- * / \
- * focusable1 focusable2
- * </pre>
- */
- @Test
- public void testFindRotateTargetSkipScrollableContainer2() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusParkingView = mNodeBuilder.setParent(root).setFpv().build();
- AccessibilityNodeInfo recyclerView = mNodeBuilder
- .setParent(root)
- .setScrollableContainer()
- .setScrollable(true)
- .build();
- AccessibilityNodeInfo focusable1 = mNodeBuilder.setParent(recyclerView).build();
- AccessibilityNodeInfo focusable2 = mNodeBuilder.setParent(recyclerView).build();
-
- int direction = View.FOCUS_BACKWARD;
- when(focusable2.focusSearch(direction)).thenReturn(focusable1);
- when(focusable1.focusSearch(direction)).thenReturn(recyclerView);
- when(recyclerView.focusSearch(direction)).thenReturn(focusParkingView);
-
- FindRotateTargetResult target = mNavigator.findRotateTarget(focusable2, direction, 2);
- assertThat(target.node).isSameInstanceAs(focusable1);
- assertThat(target.advancedCount).isEqualTo(1);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following node tree:
- * <pre>
- * node
- * </pre>
- */
- @Test
- public void testFindRotateTargetWithOneNode() {
- AccessibilityNodeInfo node = mNodeBuilder.build();
- int direction = View.FOCUS_BACKWARD;
- when(node.focusSearch(direction)).thenReturn(node);
-
- FindRotateTargetResult target = mNavigator.findRotateTarget(node, direction, 1);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following layout:
- * <pre>
- * ============ focus area ============
- * = =
- * = ***** scrollable container **** =
- * = * * =
- * = * ........ button 1 ........ * =
- * = * . . * =
- * = * .......................... * =
- * = * * =
- * = * ........ button 2 ........ * =
- * = * . . * =
- * = * .......................... * =
- * = * * =
- * = ******************************* =
- * = =
- * ============ focus area ============
- *
- * ........ button 3 ........
- * . .
- * ..........................
- * </pre>
- * where {@code button 3} is not a descendant of the scrollable container.
- */
- @Test
- public void testFindRotateTargetInScrollableContainer() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusArea = mNodeBuilder
- .setParent(root)
- .setFocusArea()
- .setBoundsInScreen(new Rect(0, 0, 100, 100))
- .build();
- AccessibilityNodeInfo scrollableContainer = mNodeBuilder
- .setParent(focusArea)
- .setScrollableContainer()
- .setActions(ACTION_SCROLL_FORWARD)
- .setBoundsInScreen(new Rect(0, 0, 100, 100))
- .build();
-
- AccessibilityNodeInfo button1 = mNodeBuilder
- .setParent(scrollableContainer)
- .setBoundsInScreen(new Rect(0, 0, 100, 50))
- .build();
- AccessibilityNodeInfo button2 = mNodeBuilder
- .setParent(scrollableContainer)
- .setBoundsInScreen(new Rect(0, 50, 100, 100))
- .build();
- AccessibilityNodeInfo button3 = mNodeBuilder
- .setParent(root)
- .setBoundsInScreen(new Rect(0, 100, 100, 150))
- .build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(null);
-
- // Rotate once, the focus should move from button1 to button2.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
-
- // Rotate twice, the focus should move from button1 to button2 since button3 is not a
- // descendant of the scrollable container.
- target = mNavigator.findRotateTarget(button1, direction, 2);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
-
- // Rotate three times should do the same.
- target = mNavigator.findRotateTarget(button1, direction, 3);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
- }
-
- /**
- * Tests {@link Navigator#findRotateTarget} in the following layout:
- * <pre>
- * ============ focus area ============
- * = =
- * = ***** scrollable container **** =
- * = * * =
- * = * ........ button 1 ........ * =
- * = * . . * =
- * = * .......................... * =
- * = * * =
- * = * ........ button 2 ........ * =
- * = * . . * =
- * = * .......................... * =
- * = * * =
- * = ******************************* =
- * = =
- * ============ focus area ============
- *
- * ........ button 3 ........
- * . .
- * ..........................
- * </pre>
- * where {@code button 3} is off the screen.
- */
- @Test
- public void testFindRotateTargetInScrollableContainer2() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusArea = mNodeBuilder
- .setParent(root)
- .setFocusArea()
- .setBoundsInScreen(new Rect(0, 0, 100, 100))
- .build();
- AccessibilityNodeInfo scrollableContainer = mNodeBuilder
- .setParent(focusArea)
- .setScrollableContainer()
- .setActions(ACTION_SCROLL_FORWARD)
- .setBoundsInScreen(new Rect(0, 0, 100, 100))
- .build();
-
- AccessibilityNodeInfo button1 = mNodeBuilder
- .setParent(scrollableContainer)
- .setBoundsInScreen(new Rect(0, 0, 100, 50))
- .build();
- AccessibilityNodeInfo button2 = mNodeBuilder
- .setParent(scrollableContainer)
- .setBoundsInScreen(new Rect(0, 50, 100, 100))
- .build();
- AccessibilityNodeInfo button3 = mNodeBuilder
- .setParent(root)
- .setBoundsInScreen(new Rect(0, 0, 0, 0))
- .build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(null);
-
- // Rotate once, the focus should move from button1 to button2.
- FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
-
- // Rotate twice, the focus should move from button1 to button2 since button3 is off the
- // screen.
- target = mNavigator.findRotateTarget(button1, direction, 2);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
-
- // Rotate three times should do the same.
- target = mNavigator.findRotateTarget(button1, direction, 3);
- assertThat(target.node).isSameInstanceAs(button2);
- assertThat(target.advancedCount).isEqualTo(1);
- }
-
- /**
- * Tests {@link Navigator#findScrollableContainer} in the following node tree:
- * <pre>
- * root
- * |
- * |
- * focusArea
- * / \
- * / \
- * scrolling button2
- * container
- * |
- * |
- * container
- * |
- * |
- * button1
- * </pre>
- */
- @Test
- public void testFindScrollableContainer() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo focusArea = mNodeBuilder.setParent(root).setFocusArea().build();
- AccessibilityNodeInfo scrollableContainer = mNodeBuilder
- .setParent(focusArea)
- .setScrollableContainer()
- .build();
- AccessibilityNodeInfo container = mNodeBuilder.setParent(scrollableContainer).build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(focusArea).build();
-
- AccessibilityNodeInfo target = mNavigator.findScrollableContainer(button1);
- assertThat(target).isSameInstanceAs(scrollableContainer);
- target = mNavigator.findScrollableContainer(button2);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findFocusableDescendantInDirection} going
- * * {@link View#FOCUS_BACKWARD} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * container1 container2
- * / \ / \
- * / \ / \
- * button1 button2 button3 button4
- * </pre>
- */
- @Test
- public void testFindFocusableVisibleDescendantInDirectionBackward() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo container1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo container2 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button3 = mNodeBuilder.setParent(container2).build();
- AccessibilityNodeInfo button4 = mNodeBuilder.setParent(container2).build();
-
- int direction = View.FOCUS_BACKWARD;
- when(button4.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button1);
- when(button1.focusSearch(direction)).thenReturn(null);
-
- AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(
- container2, button4, View.FOCUS_BACKWARD);
- assertThat(target).isSameInstanceAs(button3);
- target = mNavigator.findFocusableDescendantInDirection(container2, button3,
- View.FOCUS_BACKWARD);
- assertThat(target).isNull();
- target = mNavigator.findFocusableDescendantInDirection(container1, button2,
- View.FOCUS_BACKWARD);
- assertThat(target).isSameInstanceAs(button1);
- target = mNavigator.findFocusableDescendantInDirection(container1, button1,
- View.FOCUS_BACKWARD);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findFocusableDescendantInDirection} going
- * {@link View#FOCUS_FORWARD} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * container1 container2
- * / \ / \
- * / \ / \
- * button1 button2 button3 button4
- * </pre>
- */
- @Test
- public void testFindFocusableVisibleDescendantInDirectionForward() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo container1 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo container2 = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button3 = mNodeBuilder.setParent(container2).build();
- AccessibilityNodeInfo button4 = mNodeBuilder.setParent(container2).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(button4);
- when(button4.focusSearch(direction)).thenReturn(null);
-
- AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(
- container1, button1, View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button2);
- target = mNavigator.findFocusableDescendantInDirection(container1, button2,
- View.FOCUS_FORWARD);
- assertThat(target).isNull();
- target = mNavigator.findFocusableDescendantInDirection(container2, button3,
- View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button4);
- target = mNavigator.findFocusableDescendantInDirection(container2, button4,
- View.FOCUS_FORWARD);
- assertThat(target).isNull();
- }
-
- /**
- * Tests {@link Navigator#findNextFocusableDescendant} in the following node tree:
- * <pre>
- * root
- * |
- * |
- * container
- * / / \ \
- * / / \ \
- * button1 button2 button3 button4
- * </pre>
- * where {@code button3} and {@code button4} have empty bounds.
- */
- @Test
- public void testFindNextFocusableDescendantWithEmptyBounds() {
- AccessibilityNodeInfo root = mNodeBuilder.build();
- AccessibilityNodeInfo container = mNodeBuilder.setParent(root).build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(container).build();
- AccessibilityNodeInfo button3 = mNodeBuilder.setParent(container)
- .setBoundsInScreen(new Rect(5, 10, 5, 10)).build();
- AccessibilityNodeInfo button4 = mNodeBuilder.setParent(container)
- .setBoundsInScreen(new Rect(20, 40, 20, 40)).build();
-
- int direction = View.FOCUS_FORWARD;
- when(button1.focusSearch(direction)).thenReturn(button2);
- when(button2.focusSearch(direction)).thenReturn(button3);
- when(button3.focusSearch(direction)).thenReturn(button4);
- when(button4.focusSearch(direction)).thenReturn(button1);
-
- AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(container,
- button1, View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button2);
- target = mNavigator.findFocusableDescendantInDirection(container, button2,
- View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button1);
- target = mNavigator.findFocusableDescendantInDirection(container, button3,
- View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button1);
- target = mNavigator.findFocusableDescendantInDirection(container, button4,
- View.FOCUS_FORWARD);
- assertThat(target).isSameInstanceAs(button1);
- }
-
- /**
- * Tests {@link Navigator#findFirstFocusableDescendant} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * container1 container2
- * / \ / \
- * / \ / \
- * button1 button2 button3 button4
- * </pre>
- * where {@code button1} and {@code button2} are disabled.
- */
- @Test
- public void testFindFirstFocusableDescendant() {
- AccessibilityNodeInfo root = mNodeBuilder.setFocusable(false).build();
- AccessibilityNodeInfo container1 = mNodeBuilder
- .setParent(root)
- .setFocusable(false)
- .build();
- AccessibilityNodeInfo button1 = mNodeBuilder
- .setParent(container1)
- .setEnabled(false)
- .build();
- AccessibilityNodeInfo button2 = mNodeBuilder
- .setParent(container1)
- .setEnabled(false)
- .build();
- AccessibilityNodeInfo container2 = mNodeBuilder
- .setParent(root)
- .setFocusable(false)
- .build();
- AccessibilityNodeInfo button3 = mNodeBuilder.setParent(container2).build();
- AccessibilityNodeInfo button4 = mNodeBuilder.setParent(container2).build();
-
- AccessibilityNodeInfo target = mNavigator.findFirstFocusableDescendant(root);
- assertThat(target).isSameInstanceAs(button3);
- }
-
- /**
- * Tests {@link Navigator#findLastFocusableDescendant} in the following node tree:
- * <pre>
- * root
- * / \
- * / \
- * container1 container2
- * / \ / \
- * / \ / \
- * button1 button2 button3 button4
- * </pre>
- * where {@code button3} and {@code button4} are disabled.
- */
- @Test
- public void testFindLastFocusableDescendant() {
- AccessibilityNodeInfo root = mNodeBuilder.setFocusable(false).build();
- AccessibilityNodeInfo container1 = mNodeBuilder
- .setParent(root)
- .setFocusable(false)
- .build();
- AccessibilityNodeInfo button1 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo button2 = mNodeBuilder.setParent(container1).build();
- AccessibilityNodeInfo container2 = mNodeBuilder
- .setParent(root)
- .setFocusable(false)
- .build();
- AccessibilityNodeInfo button3 = mNodeBuilder
- .setParent(container2)
- .setEnabled(false)
- .build();
- AccessibilityNodeInfo button4 = mNodeBuilder
- .setParent(container2)
- .setEnabled(false)
- .build();
-
- AccessibilityNodeInfo target = mNavigator.findLastFocusableDescendant(root);
- assertThat(target).isSameInstanceAs(button2);
- }
-
- /** Sets the {@code root} node in the {@code window}'s hierarchy. */
- private void setRootNodeForWindow(@NonNull AccessibilityNodeInfo root,
- @NonNull AccessibilityWindowInfo window) {
- when(window.getRoot()).thenReturn(root);
- }
-}
diff --git a/tests/robotests/src/com/android/car/rotary/TreeTraverserTest.java b/tests/robotests/src/com/android/car/rotary/TreeTraverserTest.java
deleted file mode 100644
index d7700b4..0000000
--- a/tests/robotests/src/com/android/car/rotary/TreeTraverserTest.java
+++ /dev/null
@@ -1,282 +0,0 @@
-/*
- * Copyright (C) 2020 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.android.car.rotary;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import android.view.accessibility.AccessibilityNodeInfo;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-
-import java.util.ArrayList;
-import java.util.List;
-
-@RunWith(RobolectricTestRunner.class)
-public class TreeTraverserTest {
-
- private TreeTraverser mTreeTraverser;
- private NodeBuilder mNodeBuilder;
-
- @Before
- public void setUp() {
- mTreeTraverser = new TreeTraverser();
- mTreeTraverser.setNodeCopier(MockNodeCopierProvider.get());
- mNodeBuilder = new NodeBuilder(new ArrayList<>());
- }
-
- /**
- * Tests
- * {@link TreeTraverser#findNodeOrAncestor(AccessibilityNodeInfo, NodePredicate, NodePredicate)}
- * in the following node tree:
- * <pre>
- * node0
- * / \
- * / \
- * node1 node4
- * / \ / \
- * / \ / \
- * node2 node3 node5 node6
- * </pre>
- */
- @Test
- public void testFindNodeOrAncestor() {
- AccessibilityNodeInfo node0 = mNodeBuilder.build();
- AccessibilityNodeInfo node1 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node2 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node3 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node4 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node5 = mNodeBuilder.setParent(node4).build();
- AccessibilityNodeInfo node6 = mNodeBuilder.setParent(node4).build();
-
- // Should check the node itself.
- AccessibilityNodeInfo result = mTreeTraverser.findNodeOrAncestor(node0,
- /* stopPredicate= */ null, /* targetPredicate= */ node -> node == node0);
- assertThat(result).isSameInstanceAs(node0);
-
- // Parent.
- result = mTreeTraverser.findNodeOrAncestor(node1, /* stopPredicate= */ null,
- /* targetPredicate= */ node -> node == node0);
- assertThat(result).isSameInstanceAs(node0);
-
- // Grandparent.
- result = mTreeTraverser.findNodeOrAncestor(node2, /* stopPredicate= */ null,
- /* targetPredicate= */ node -> node == node0);
- assertThat(result).isSameInstanceAs(node0);
-
- // No ancestor found.
- result = mTreeTraverser.findNodeOrAncestor(node2, /* stopPredicate= */ null,
- /* targetPredicate= */ node -> node == node6);
- assertThat(result).isNull();
-
- // Stop before target.
- result = mTreeTraverser.findNodeOrAncestor(node2, /* stopPredicate= */
- node -> node == node1,
- /* targetPredicate= */ node -> node == node0);
- assertThat(result).isNull();
-
- // Stop at target.
- result = mTreeTraverser.findNodeOrAncestor(node2, /* stopPredicate= */
- node -> node == node0,
- /* targetPredicate= */ node -> node == node0);
- assertThat(result).isNull();
- }
-
- /**
- * Tests {@link TreeTraverser#depthFirstSearch(AccessibilityNodeInfo, NodePredicate,
- * NodePredicate)}
- * in the following node tree:
- * <pre>
- * node0
- * / \
- * / \
- * node1 node4
- * / \ / \
- * / \ / \
- * node2 node3 node5 node6
- * </pre>
- */
- @Test
- public void testDepthFirstSearch() {
- AccessibilityNodeInfo node0 = mNodeBuilder.build();
- AccessibilityNodeInfo node1 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node2 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node3 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node4 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node5 = mNodeBuilder.setParent(node4).build();
- AccessibilityNodeInfo node6 = mNodeBuilder.setParent(node4).build();
-
- // Iterate in depth-first order, finding nothing.
- List<AccessibilityNodeInfo> targetPredicateCalledWithNodes = new ArrayList<>();
- AccessibilityNodeInfo result = mTreeTraverser.depthFirstSearch(
- node0,
- /* skipPredicate= */ null,
- node -> {
- targetPredicateCalledWithNodes.add(node);
- return false;
- });
- assertThat(result).isNull();
- assertThat(targetPredicateCalledWithNodes).containsExactly(
- node0, node1, node2, node3, node4, node5, node6);
-
- // Find root.
- result = mTreeTraverser.depthFirstSearch(node0, /* skipPredicate= */ null,
- /* targetPredicate= */ node -> node == node0);
- assertThat(result).isSameInstanceAs(node0);
-
- // Find child.
- result = mTreeTraverser.depthFirstSearch(node0, /* skipPredicate= */ null,
- /* targetPredicate= */ node -> node == node4);
- assertThat(result).isSameInstanceAs(node4);
-
- // Find grandchild.
- result = mTreeTraverser.depthFirstSearch(node0, /* skipPredicate= */ null,
- /* targetPredicate= */ node -> node == node6);
- assertThat(result).isSameInstanceAs(node6);
-
- // Iterate in depth-first order, skipping a subtree containing the target
- List<AccessibilityNodeInfo> skipPredicateCalledWithNodes = new ArrayList<>();
- targetPredicateCalledWithNodes.clear();
- result = mTreeTraverser.depthFirstSearch(node0,
- node -> {
- skipPredicateCalledWithNodes.add(node);
- return node == node1;
- },
- node -> {
- targetPredicateCalledWithNodes.add(node);
- return node == node2;
- });
- assertThat(result).isNull();
- assertThat(skipPredicateCalledWithNodes).containsExactly(node0, node1, node4, node5, node6);
- assertThat(targetPredicateCalledWithNodes).containsExactly(node0, node4, node5, node6);
-
- // Skip subtree whose root is the target.
- result = mTreeTraverser.depthFirstSearch(node0,
- /* skipPredicate= */ node -> node == node1,
- /* skipPredicate= */ node -> node == node1);
- assertThat(result).isNull();
- }
-
- /**
- * Tests {@link TreeTraverser#reverseDepthFirstSearch} in the following node tree:
- * <pre>
- * node0
- * / \
- * / \
- * node1 node4
- * / \ / \
- * / \ / \
- * node2 node3 node5 node6
- * </pre>
- */
- @Test
- public void testReverseDepthFirstSearch() {
- AccessibilityNodeInfo node0 = mNodeBuilder.build();
- AccessibilityNodeInfo node1 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node2 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node3 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node4 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node5 = mNodeBuilder.setParent(node4).build();
- AccessibilityNodeInfo node6 = mNodeBuilder.setParent(node4).build();
-
- // Iterate in reverse depth-first order, finding nothing.
- List<AccessibilityNodeInfo> predicateCalledWithNodes = new ArrayList<>();
- AccessibilityNodeInfo result = mTreeTraverser.reverseDepthFirstSearch(
- node0,
- node -> {
- predicateCalledWithNodes.add(node);
- return false;
- });
- assertThat(result).isNull();
- assertThat(predicateCalledWithNodes).containsExactly(
- node6, node5, node4, node3, node2, node1, node0);
-
- // Find root.
- result = mTreeTraverser.reverseDepthFirstSearch(node0, node -> node == node0);
- assertThat(result).isSameInstanceAs(node0);
-
- // Find child.
- result = mTreeTraverser.reverseDepthFirstSearch(node0, node -> node == node1);
- assertThat(result).isSameInstanceAs(node1);
-
- // Find grandchild.
- result = mTreeTraverser.reverseDepthFirstSearch(node0, node -> node == node2);
- assertThat(result).isSameInstanceAs(node2);
- }
-
- /**
- * Tests {@link TreeTraverser#depthFirstSelect} in the following node tree:
- * <pre>
- * node0
- * / \
- * / \
- * node1 node4
- * / \ / \
- * / \ / \
- * node2 node3 node5 node6
- * </pre>
- */
- @Test
- public void testDepthFirstSelect() {
- AccessibilityNodeInfo node0 = mNodeBuilder.build();
- AccessibilityNodeInfo node1 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node2 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node3 = mNodeBuilder.setParent(node1).build();
- AccessibilityNodeInfo node4 = mNodeBuilder.setParent(node0).build();
- AccessibilityNodeInfo node5 = mNodeBuilder.setParent(node4).build();
- AccessibilityNodeInfo node6 = mNodeBuilder.setParent(node4).build();
-
- // Iterate in depth-first order, selecting no nodes.
- List<AccessibilityNodeInfo> predicateCalledWithNodes = new ArrayList<>();
- List<AccessibilityNodeInfo> selectedNodes = new ArrayList<>();
- mTreeTraverser.depthFirstSelect(node0, node -> {
- predicateCalledWithNodes.add(node);
- return false;
- }, selectedNodes);
- assertThat(predicateCalledWithNodes).containsExactly(
- node0, node1, node2, node3, node4, node5, node6);
- assertThat(selectedNodes).isEmpty();
-
- // Find any node. Selects root and skips descendents.
- predicateCalledWithNodes.clear();
- selectedNodes = new ArrayList<>();
- mTreeTraverser.depthFirstSelect(node0, node -> {
- predicateCalledWithNodes.add(node);
- return true;
- }, selectedNodes);
- assertThat(predicateCalledWithNodes).containsExactly(node0);
- assertThat(selectedNodes).containsExactly(node0);
-
- // Find children of root node. Skips grandchildren.
- predicateCalledWithNodes.clear();
- selectedNodes = new ArrayList<>();
- mTreeTraverser.depthFirstSelect(node0, node -> {
- predicateCalledWithNodes.add(node);
- return node == node1 || node == node4;
- }, selectedNodes);
- assertThat(predicateCalledWithNodes).containsExactly(node0, node1, node4);
- assertThat(selectedNodes).containsExactly(node1, node4);
-
- // Find grandchildren of root node.
- selectedNodes = new ArrayList<>();
- mTreeTraverser.depthFirstSelect(node0,
- node -> node == node2 || node == node3 || node == node5 || node == node6,
- selectedNodes);
- assertThat(selectedNodes).containsExactly(node2, node3, node5, node6);
- }
-}
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index bfc3e5b..394611d 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -12,19 +12,23 @@ android_test {
libs: [
"android.test.runner",
"android.test.base",
+ "android.test.mock",
],
static_libs: [
+ "CarRotaryControllerForUnitTesting",
"android.car",
"androidx.test.core",
"androidx.test.rules",
"androidx.test.ext.junit",
"androidx.test.ext.truth",
- "mockito-target-minus-junit4",
+ "mockito-target-extended-minus-junit4",
"platform-test-annotations",
"truth-prebuilt",
"testng",
],
- instrumentation_for: "CarRotaryController",
+ jni_libs: ["libdexmakerjvmtiagent", "libstaticjvmtiagent"],
+
+ aaptflags: ["--extra-packages com.android.car.rotary"],
}
diff --git a/tests/unit/AndroidManifest.xml b/tests/unit/AndroidManifest.xml
index 3e23477..ba3bd43 100644
--- a/tests/unit/AndroidManifest.xml
+++ b/tests/unit/AndroidManifest.xml
@@ -17,14 +17,22 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
package="com.android.car.rotary.tests.unit">
+ <uses-permission android:name="android.car.permission.CAR_MONITOR_INPUT"/>
+ <uses-permission android:name="android.permission.INJECT_EVENTS"/>
+ <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
+ <uses-permission android:name="android.permission.INTERNAL_SYSTEM_WINDOW"/>
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+ <uses-permission android:name="android.permission.GET_ACCOUNTS_PRIVILEGED"/>
+
<application android:debuggable="true">
<uses-library android:name="android.test.runner" />
+ <activity android:name="com.android.car.rotary.NavigatorTestActivity" />
+ <activity android:name="com.android.car.rotary.TreeTraverserTestActivity" />
</application>
<instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
- android:targetPackage="com.android.car.rotary"
+ android:targetPackage="com.android.car.rotary.tests.unit"
android:label="Car Rotary Unit Tests" />
</manifest>
diff --git a/tests/unit/res/layout/navigator_find_focus_parking_view_test_activity.xml b/tests/unit/res/layout/navigator_find_focus_parking_view_test_activity.xml
new file mode 100644
index 0000000..bd4b08a
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_focus_parking_view_test_activity.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / \
+ / \
+ parent button
+ |
+ |
+ focusParkingView
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <LinearLayout
+ android:id="@+id/parent"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </LinearLayout>
+ <Button
+ android:id="@+id/button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_focusable_descendant_empty_bounds_test_activity.xml b/tests/unit/res/layout/navigator_find_focusable_descendant_empty_bounds_test_activity.xml
new file mode 100644
index 0000000..717dfc3
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_focusable_descendant_empty_bounds_test_activity.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ |
+ |
+ container
+ / / \ \
+ / / \ \
+ button1 button2 button3 button4
+where button3 and button4 have empty bounds.
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ <LinearLayout
+ android:id="@+id/container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ <Button
+ android:id="@+id/button3"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:text="Button3"/>
+ <Button
+ android:id="@+id/button4"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:text="Button4"/>
+ </LinearLayout>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_focusable_descendant_test_activity.xml b/tests/unit/res/layout/navigator_find_focusable_descendant_test_activity.xml
new file mode 100644
index 0000000..0ffccb3
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_focusable_descendant_test_activity.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure.
+
+ root
+ / \
+ / \
+ container1 container2
+ / \ / \
+ / \ / \
+ button1 button2 button3 button4
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ <LinearLayout
+ android:id="@+id/container1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ </LinearLayout>
+ <LinearLayout
+ android:id="@+id/container2"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+ <Button
+ android:id="@+id/button3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button3"/>
+ <Button
+ android:id="@+id/button4"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button4"/>
+ </LinearLayout>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_nudge_target_focus_area_1_test_activity.xml b/tests/unit/res/layout/navigator_find_nudge_target_focus_area_1_test_activity.xml
new file mode 100644
index 0000000..defcc60
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_nudge_target_focus_area_1_test_activity.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area1"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:topBoundOffset="200dp">
+ <com.android.car.ui.recyclerview.CarUiRecyclerView
+ android:id="@+id/scrollable_container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:rotaryScrollEnabled="true"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area2"
+ android:layout_width="match_parent"
+ android:layout_height="200dp">
+ <View
+ android:id="@+id/view"
+ android:focusable="true"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
+ </com.android.car.ui.FocusArea>
+</FrameLayout>
diff --git a/tests/unit/res/layout/navigator_find_nudge_target_focus_area_2_test_activity.xml b/tests/unit/res/layout/navigator_find_nudge_target_focus_area_2_test_activity.xml
new file mode 100644
index 0000000..7fe322c
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_nudge_target_focus_area_2_test_activity.xml
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+ The following layout represents this structure.
+
+ ========topLeft focus area======== ========topRight focus area========
+ = = = =
+ = ............. ............. = = ............. =
+ = . . . . = = . . =
+ = . topLeft1 . . topLeft2 . = = . topRight1 . =
+ = . . . . = = . . =
+ = ............. ............. = = ............. =
+ = = = =
+ ================================== ===================================
+
+ =======middleLeft focus area======
+ = =
+ = ............. ............. =
+ = . . . . =
+ = .middleLeft1. .middleLeft2. =
+ = . disabled . . disabled . =
+ = ............. ............. =
+ = =
+ ==================================
+
+ =======bottomLeft focus area======
+ = =
+ = ............. ............. =
+ = . . . . =
+ = .bottomLeft1. .bottomLeft2. =
+ = . . . . =
+ = ............. ............. =
+ = =
+ ==================================
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/top_left"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/top_left1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/top_left2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/middle_left"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/middle_left1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:enabled="false"/>
+ <Button
+ android:id="@+id/middle_left2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:enabled="false"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/bottom_left"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/bottom_left1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/bottom_left2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ </LinearLayout>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/top_right"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/top_right1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_does_not_skip_offscreen_node_test_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_does_not_skip_offscreen_node_test_activity.xml
new file mode 100644
index 0000000..f6d1896
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_does_not_skip_offscreen_node_test_activity.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ ============ focus area ============
+ = =
+ = ***** recycler view **** =
+ = * * =
+ = * ........ text 1 ........ * =
+ = * . visible . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 2 ........ * =
+ = * . visible . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 3 ........ * =
+ = * . offscreen ....... * =
+ = * .......................... * =
+ = * * =
+ = ******************************* =
+ = =
+ ============ focus area ============
+
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <!-- Three focusable views will be added via code. Each view has a height of 100dp,
+ (2 visible, 1 not visible) so the height of this recycler view will be 200dp. -->
+ <com.android.car.ui.recyclerview.CarUiRecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="match_parent"
+ android:layout_height="200dp"
+ android:background="@android:color/white"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_1_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_1_activity.xml
new file mode 100644
index 0000000..23df830
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_1_activity.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ ============ focus area 1 ==========
+ = =
+ = ***** scrollable container **** =
+ = * * =
+ = * ........ text 1 ........ * =
+ = * . . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 2 ........ * =
+ = * . . * =
+ = * .......................... * =
+ = * * =
+ = ******************************* =
+ = =
+ ============ focus area 1 ==========
+ ============ focus area 2 ==========
+ = ........ text 3 ........ =
+ = . . =
+ = .......................... =
+ ============ focus area 2 ==========
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <!-- Two focusable views will be added via code. Each view has a height of 100dp so the
+ height of this recycler view will be 200dp. -->
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="wrap_content"
+ android:layout_height="200dp"
+ android:background="@android:color/white"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea2"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <TextView
+ android:id="@+id/text3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button3"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout> \ No newline at end of file
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_2_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_2_activity.xml
new file mode 100644
index 0000000..6f4cdba
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_in_scrollable_container_test_2_activity.xml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ ============ focus area ============
+ = =
+ = ***** scrollable container **** =
+ = * * =
+ = * ........ text 1 ........ * =
+ = * . visible . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 2 ........ * =
+ = * . visible . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 3 ........ * =
+ = * . not visible . * =
+ = * .......................... * =
+ = * * =
+ = ******************************* =
+ = =
+ ============ focus area ============
+
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea1"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <!-- Three focusable views will be added via code. Each view has a height of 100dp,
+ (2 visible, 1 not visible) so the height of this recycler view will be 200dp. -->
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="wrap_content"
+ android:layout_height="200dp"
+ android:background="@android:color/white"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_activity.xml
new file mode 100644
index 0000000..62ecf77
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_activity.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / \
+ / \
+ focusParkingView focusArea
+ / \
+ / \
+ button1 button2
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_generic_fpv_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_generic_fpv_activity.xml
new file mode 100644
index 0000000..740ba81
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_1_generic_fpv_activity.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / \
+ / \
+ focusArea genericFocusParkingView
+ / \
+ / \
+ button1 button2
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.rotary.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_activity.xml
new file mode 100644
index 0000000..4c136bd
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_activity.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / | \
+ / | \
+ / | \
+ focusParkingView button1 button2
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_generic_fpv_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_generic_fpv_activity.xml
new file mode 100644
index 0000000..47888a7
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_no_wrap_test_2_generic_fpv_activity.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / | \
+ / | \
+ / | \
+ button1 button2 genericFocusParkingView
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ <com.android.car.rotary.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_one_node_test_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_one_node_test_activity.xml
new file mode 100644
index 0000000..c7204de
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_one_node_test_activity.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ node
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/node"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_scrollable_container_test_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_scrollable_container_test_activity.xml
new file mode 100644
index 0000000..a3ce321
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_scrollable_container_test_activity.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / | \
+ / | \
+ / | \
+ button1 scrollable button2
+ recyclerView
+ |
+ non-focusable
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <!-- Non-focusable view to be added in code. It will have a height greater than 50dp. -->
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="wrap_content"
+ android:layout_height="50dp"
+ android:background="@android:color/white"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_1_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_1_activity.xml
new file mode 100644
index 0000000..69c1756
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_1_activity.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / | \
+ / | \
+ / | \
+ button1 scrollable button2
+ recyclerView
+ |
+ non-focusable
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <!-- One non-focusable view will be added via code. -->
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_2_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_2_activity.xml
new file mode 100644
index 0000000..69cf454
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_skip_scrollable_container_test_2_activity.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure.
+
+ ============ focus area ============
+ = =
+ = ***** scrollable container **** =
+ = * * =
+ = * ........ text 1 ........ * =
+ = * . . * =
+ = * .......................... * =
+ = * * =
+ = * ........ text 2 ........ * =
+ = * . . * =
+ = * .......................... * =
+ = * * =
+ = ******************************* =
+ = =
+ ============ focus area ============
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <!-- Two focusable views will be added via code. Each view has a height of 100dp, so the height
+ should be 200dp to ensure both are fully visible. -->
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/scrollable"
+ android:layout_width="wrap_content"
+ android:layout_height="200dp"
+ android:background="@android:color/white"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_rotate_target_test_activity.xml b/tests/unit/res/layout/navigator_find_rotate_target_test_activity.xml
new file mode 100644
index 0000000..7ec8044
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_rotate_target_test_activity.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure.
+
+ root
+ |
+ focusArea
+ / | \
+ / | \
+ button1 button2 button3
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ <Button
+ android:id="@+id/button3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button3"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_find_scrollable_container_test_activity.xml b/tests/unit/res/layout/navigator_find_scrollable_container_test_activity.xml
new file mode 100644
index 0000000..a523cb8
--- /dev/null
+++ b/tests/unit/res/layout/navigator_find_scrollable_container_test_activity.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure.
+
+ root
+ |
+ |
+ focusArea
+ / \
+ / \
+ scrolling button2
+ container
+ |
+ |
+ container
+ |
+ |
+ button1
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <FrameLayout
+ android:id="@+id/scrollableContainer"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="com.android.car.ui.utils.VERTICALLY_SCROLLABLE">
+ <FrameLayout
+ android:id="@+id/container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ </FrameLayout>
+ </FrameLayout>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/navigator_get_ancestor_focus_area_test_activity.xml b/tests/unit/res/layout/navigator_get_ancestor_focus_area_test_activity.xml
new file mode 100644
index 0000000..f449414
--- /dev/null
+++ b/tests/unit/res/layout/navigator_get_ancestor_focus_area_test_activity.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / \
+ / \
+ focusArea button3
+ / \
+ / \
+ parent button2
+ |
+ |
+ button1
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <LinearLayout
+ android:id="@+id/parent"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button1"/>
+ </LinearLayout>
+ <Button
+ android:id="@+id/button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button2"/>
+ </com.android.car.ui.FocusArea>
+ <Button
+ android:id="@+id/button3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Button3"/>
+</LinearLayout>
diff --git a/tests/unit/res/layout/rotary_service_test_1_activity.xml b/tests/unit/res/layout/rotary_service_test_1_activity.xml
new file mode 100644
index 0000000..85ba7a8
--- /dev/null
+++ b/tests/unit/res/layout/rotary_service_test_1_activity.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ root
+ / \
+ / \
+ focusParkingView focusArea
+ / | \
+ / | \
+ button1 defaultFocus button3
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/focusParkingView"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focusArea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:defaultFocus="@+id/defaultFocus">
+ <Button
+ android:id="@+id/button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/defaultFocus"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/button3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/tests/unit/res/layout/rotary_service_test_2_activity.xml b/tests/unit/res/layout/rotary_service_test_2_activity.xml
new file mode 100644
index 0000000..efecff0
--- /dev/null
+++ b/tests/unit/res/layout/rotary_service_test_2_activity.xml
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2021 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.
+ -->
+
+<!--
+ The following layout represents this structure.
+
+ The HUN window:
+
+ HUN FocusParkingView
+ ==========HUN focus area==========
+ = =
+ = ............. ............. =
+ = . . . . =
+ = .hun button1. .hun button2. =
+ = . . . . =
+ = ............. ............. =
+ = =
+ ==================================
+
+ The app window:
+
+ app FocusParkingView
+ ===========focus area 1=========== ============focus area 2===========
+ = = = =
+ = ............. ............. = = ............. =
+ = . . . . = = . . =
+ = .app button1. . nudge . = = .app button2. =
+ = . . . shortcut . = = . . =
+ = ............. ............. = = ............. =
+ = = = =
+ ================================== ===================================
+
+ ===========focus area 3===========
+ = =
+ = ............. ............. =
+ = . . . . =
+ = .app button3. . default . =
+ = . . . focus . =
+ = ............. ............. =
+ = =
+ ==================================
+-->
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <LinearLayout
+ android:id="@+id/hun_root"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/hun_fpv"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/hun_focus_area"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <Button
+ android:id="@+id/hun_button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/hun_button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ </LinearLayout>
+ <LinearLayout
+ android:id="@+id/app_root"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/app_fpv"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content">
+ <com.android.car.ui.FocusArea
+ android:id="@+id/app_focus_area1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:nudgeShortcut="@+id/nudge_shortcut"
+ app:nudgeShortcutDirection="right">
+ <Button
+ android:id="@+id/app_button1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/nudge_shortcut"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/app_focus_area2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:nudgeLeft="@+id/app_focus_area3">
+ <Button
+ android:id="@+id/app_button2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ </LinearLayout>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/app_focus_area3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:defaultFocus="@+id/app_default_focus">
+ <Button
+ android:id="@+id/app_button3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <Button
+ android:id="@+id/app_default_focus"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+ </LinearLayout>
+</FrameLayout> \ No newline at end of file
diff --git a/tests/unit/res/layout/test_recycler_view_view_holder.xml b/tests/unit/res/layout/test_recycler_view_view_holder.xml
new file mode 100644
index 0000000..f95ed16
--- /dev/null
+++ b/tests/unit/res/layout/test_recycler_view_view_holder.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+<TextView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/item"
+ android:layout_width="wrap_content"
+ android:layout_height="100dp"/>
diff --git a/tests/unit/res/layout/tree_traverser_test_activity.xml b/tests/unit/res/layout/tree_traverser_test_activity.xml
new file mode 100644
index 0000000..0f22c3f
--- /dev/null
+++ b/tests/unit/res/layout/tree_traverser_test_activity.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2020 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.
+ -->
+
+<!--
+The following layout represents this structure:
+
+ node0
+ / \
+ / \
+ node1 node4
+ / \ / \
+ / \ / \
+ node2 node3 node5 node6
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/node0"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:contentDescription="node0"
+ android:orientation="vertical"
+ android:focusable="true">
+ <LinearLayout
+ android:id="@+id/node1"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:contentDescription="node1"
+ android:orientation="horizontal"
+ android:focusable="true">
+ <View
+ android:id="@+id/node2"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:contentDescription="node2"
+ android:focusable="true"/>
+ <View
+ android:id="@+id/node3"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:contentDescription="node3"
+ android:focusable="true"/>
+ </LinearLayout>
+ <LinearLayout
+ android:id="@+id/node4"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:contentDescription="node4"
+ android:orientation="horizontal"
+ android:focusable="true">
+ <View
+ android:id="@+id/node5"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:contentDescription="node5"
+ android:focusable="true"/>
+ <View
+ android:id="@+id/node6"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:contentDescription="node6"
+ android:focusable="true"/>
+ </LinearLayout>
+</LinearLayout>
diff --git a/tests/robotests/src/com/android/car/rotary/FocusFinderTest.java b/tests/unit/src/com/android/car/rotary/FocusFinderTest.java
index fa89671..f17d9c3 100755
--- a/tests/robotests/src/com/android/car/rotary/FocusFinderTest.java
+++ b/tests/unit/src/com/android/car/rotary/FocusFinderTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * Copyright (C) 2020 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.
@@ -16,17 +16,22 @@
package com.android.car.rotary;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.fail;
+import static org.testng.AssertJUnit.assertFalse;
+import static org.testng.AssertJUnit.assertTrue;
+
import android.graphics.Rect;
-import android.test.AndroidTestCase;
import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
/** Most of the tests are copied from {@link android.view.FocusFinderTest}. */
-@RunWith(RobolectricTestRunner.class)
-public class FocusFinderTest extends AndroidTestCase {
+@RunWith(AndroidJUnit4.class)
+public class FocusFinderTest {
@Test
public void testPartiallyInDirection() {
@@ -46,20 +51,34 @@ public class FocusFinderTest extends AndroidTestCase {
public void testInDirection() {
final Rect src = new Rect(100, 100, 200, 200);
- assertIsInDirection(View.FOCUS_LEFT, src, new Rect(99, 100, 300, 200));
- assertIsInDirection(View.FOCUS_RIGHT, src, new Rect(0, 50, 201, 60));
- assertIsInDirection(View.FOCUS_UP, src, new Rect(50, 99, 60, 300));
- assertIsInDirection(View.FOCUS_DOWN, src, new Rect(50, 0, 60, 201));
+ // Strictly in the given direction.
+ assertIsInDirection(View.FOCUS_LEFT, src, new Rect(99, 100, 200, 200));
+ assertIsInDirection(View.FOCUS_RIGHT, src, new Rect(100, 99, 201, 200));
+ assertIsInDirection(View.FOCUS_UP, src, new Rect(101, 99, 199, 200));
+ assertIsInDirection(View.FOCUS_DOWN, src, new Rect(99, 100, 201, 201));
+
+ // Loosely in the given direction.
+ assertIsInDirection(View.FOCUS_LEFT, src, new Rect(0, 0, 50, 50));
+ assertIsInDirection(View.FOCUS_RIGHT, src, new Rect(250, 50, 300, 150));
+ assertIsInDirection(View.FOCUS_UP, src, new Rect(250, 50, 300, 150));
+ assertIsInDirection(View.FOCUS_DOWN, src, new Rect(50, 150, 150, 250));
}
@Test
public void testNotInDirection() {
final Rect src = new Rect(100, 100, 200, 200);
+ // None of destRect is in the given direction of srcRect.
assertIsNotInDirection(View.FOCUS_LEFT, src, new Rect(100, 300, 150, 400));
assertIsNotInDirection(View.FOCUS_RIGHT, src, new Rect(150, 300, 200, 400));
assertIsNotInDirection(View.FOCUS_UP, src, new Rect(300, 100, 400, 150));
assertIsNotInDirection(View.FOCUS_DOWN, src, new Rect(300, 150, 400, 200));
+
+ // destRect is strictly in other direction of srcRect.
+ assertIsNotInDirection(View.FOCUS_LEFT, src, new Rect(0, 0, 200, 50));
+ assertIsNotInDirection(View.FOCUS_RIGHT, src, new Rect(100, 300, 300, 400));
+ assertIsNotInDirection(View.FOCUS_UP, src, new Rect(50, 0, 150, 200));
+ assertIsNotInDirection(View.FOCUS_DOWN, src, new Rect(50, 100, 150, 300));
}
@Test
diff --git a/tests/robotests/src/com/android/car/rotary/MockNodeCopierProvider.java b/tests/unit/src/com/android/car/rotary/MockNodeCopierProvider.java
index 7fc56ef..51ccce5 100644
--- a/tests/robotests/src/com/android/car/rotary/MockNodeCopierProvider.java
+++ b/tests/unit/src/com/android/car/rotary/MockNodeCopierProvider.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 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.
@@ -15,20 +15,24 @@
*/
package com.android.car.rotary;
+import static org.mockito.internal.util.MockUtil.isMock;
+
import android.view.accessibility.AccessibilityNodeInfo;
import androidx.annotation.Nullable;
/** A class that can provide a mock {@link NodeCopier}. */
class MockNodeCopierProvider {
-
private static final NodeCopier sNodeCopier = new NodeCopier() {
// NodeCopier#copyNode() doesn't work when passed a mock node, so we create the mock method
- // which returns the passed node itself rather than a copy. As a result, nodes created by
- // the mock method shouldn't be recycled.
+ // which returns the passed node itself rather than a copy when the parameter is a mocked
+ // node. As a result, mocked nodes created by the mock method shouldn't be recycled.
@Override
AccessibilityNodeInfo copy(@Nullable AccessibilityNodeInfo node) {
- return node;
+ if (isMock(node)) {
+ return node;
+ }
+ return node == null ? null : AccessibilityNodeInfo.obtain(node);
}
};
diff --git a/tests/unit/src/com/android/car/rotary/NavigatorTest.java b/tests/unit/src/com/android/car/rotary/NavigatorTest.java
new file mode 100644
index 0000000..1c11450
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/NavigatorTest.java
@@ -0,0 +1,1502 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.app.UiAutomation;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.view.Display;
+import android.view.View;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import android.widget.Button;
+
+import androidx.annotation.LayoutRes;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+
+import com.android.car.rotary.Navigator.FindRotateTargetResult;
+import com.android.car.rotary.ui.TestRecyclerViewAdapter;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class NavigatorTest {
+
+ private final static String HOST_APP_PACKAGE_NAME = "host.app.package.name";
+ private final static String CLIENT_APP_PACKAGE_NAME = "client.app.package.name";
+
+ private static UiAutomation sUiAutomoation;
+
+ private final List<AccessibilityNodeInfo> mNodes = new ArrayList<>();
+
+ private ActivityTestRule<NavigatorTestActivity> mActivityRule;
+ private Intent mIntent;
+ private Rect mDisplayBounds;
+ private Rect mHunWindowBounds;
+ private Navigator mNavigator;
+ private AccessibilityNodeInfo mWindowRoot;
+ private NodeBuilder mNodeBuilder;
+
+ @BeforeClass
+ public static void oneTimeSetup() {
+ sUiAutomoation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+ }
+
+ @Before
+ public void setUp() {
+ mActivityRule = new ActivityTestRule<>(NavigatorTestActivity.class);
+ mIntent = new Intent();
+ mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
+ mDisplayBounds = new Rect(0, 0, 1080, 920);
+ mHunWindowBounds = new Rect(50, 10, 950, 200);
+ // The values of displayWidth and displayHeight don't affect the test, so just use 0.
+ mNavigator = new Navigator(/* displayWidth= */ mDisplayBounds.right,
+ /* displayHeight= */ mDisplayBounds.bottom,
+ mHunWindowBounds.left, mHunWindowBounds.right, /* showHunOnBottom= */ false);
+ mNavigator.setNodeCopier(MockNodeCopierProvider.get());
+ mNodeBuilder = new NodeBuilder(new ArrayList<>());
+ }
+
+ @After
+ public void tearDown() {
+ mActivityRule.finishActivity();
+ Utils.recycleNode(mWindowRoot);
+ Utils.recycleNodes(mNodes);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * |
+ * focusArea
+ * / | \
+ * / | \
+ * button1 button2 button3
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTarget() {
+ initActivity(R.layout.navigator_find_rotate_target_test_activity);
+
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo button3 = createNode("button3");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate once, the focus should move from button1 to button2.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+ assertThat(target.node).isEqualTo(button2);
+ assertThat(target.advancedCount).isEqualTo(1);
+
+ Utils.recycleNode(target.node);
+
+ // Rotate twice, the focus should move from button1 to button3.
+ target = mNavigator.findRotateTarget(button1, direction, 2);
+ assertThat(target.node).isEqualTo(button3);
+ assertThat(target.advancedCount).isEqualTo(2);
+
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * / \
+ * / \
+ * button1 button2
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetNoWrapAround() {
+ initActivity(R.layout.navigator_find_rotate_target_no_wrap_test_1_activity);
+
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate at the end of focus area, no wrap-around should happen.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusArea genericFocusParkingView
+ * / \
+ * / \
+ * button1 button2
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetNoWrapAroundWithGenericFpv() {
+ initActivity(R.layout.navigator_find_rotate_target_no_wrap_test_1_generic_fpv_activity);
+
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate at the end of focus area, no wrap-around should happen.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / | \
+ * / | \
+ * / | \
+ * focusParkingView button1 button2
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetNoWrapAround2() {
+ initActivity(R.layout.navigator_find_rotate_target_no_wrap_test_2_activity);
+
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate at the end of focus area, no wrap-around should happen.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / | \
+ * / | \
+ * / | \
+ * button1 button2 genericFocusParkingView
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetNoWrapAround2WithGenericFpv() {
+ initActivity(R.layout.navigator_find_rotate_target_no_wrap_test_2_generic_fpv_activity);
+
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate at the end of focus area, no wrap-around should happen.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button2, direction, 1);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * ============ focus area ============
+ * = =
+ * = ***** recycler view **** =
+ * = * * =
+ * = * ........ text 1 ........ * =
+ * = * . visible . * =
+ * = * .......................... * =
+ * = * * =
+ * = * ........ text 2 ........ * =
+ * = * . visible . * =
+ * = * .......................... * =
+ * = * * =
+ * = * ........ text 3 ........ * =
+ * = * . offscreen ....... * =
+ * = * .......................... * =
+ * = * * =
+ * = ******************************* =
+ * = =
+ * ============ focus area ============
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetDoesNotSkipOffscreenNode() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_does_not_skip_offscreen_node_test_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView recyclerView = activity.findViewById(R.id.scrollable);
+ recyclerView.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 3);
+ adapter.setItemsFocusable(true);
+ recyclerView.setAdapter(adapter);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ AccessibilityNodeInfo text1 = createNodeByText("Test Item 1");
+ AccessibilityNodeInfo text2 = createNodeByText("Test Item 2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ FindRotateTargetResult target = mNavigator.findRotateTarget(text1, direction, 1);
+ assertThat(target.node).isEqualTo(text2);
+ Utils.recycleNode(target.node);
+
+ AccessibilityNodeInfo text3 = createNodeByText("Test Item 3");
+ assertThat(text3).isNull();
+
+ target = mNavigator.findRotateTarget(text2, direction, 1);
+ // Need to query for text3 after the rotation, so that it is visible on the screen for the
+ // instrumentation to pick it up.
+ text3 = createNodeByText("Test Item 3");
+ assertThat(target.node).isEqualTo(text3);
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * |
+ * focusArea
+ * / | \
+ * / | \
+ * button1 button2 button3
+ * </pre>
+ * where {@code button2} is not focusable.
+ */
+ @Test
+ public void testFindRotateTargetSkipNodeThatCannotPerformFocus() {
+ initActivity(R.layout.navigator_find_rotate_target_test_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ View rootView = activity.findViewById(R.id.root);
+ rootView.post(() -> {
+ Button button2 = activity.findViewById(R.id.button2);
+ button2.setFocusable(false);
+ });
+
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button3 = createNode("button3");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate from button1, it should skip the empty view.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+ assertThat(target.node).isEqualTo(button3);
+
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / | \
+ * / | \
+ * / | \
+ * button1 scrollable button2
+ * recyclerView
+ * |
+ * non-focusable
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetReturnScrollableContainer() {
+ initActivity(R.layout.navigator_find_rotate_target_scrollable_container_test_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView recyclerView = activity.findViewById(R.id.scrollable);
+ recyclerView.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 1);
+ recyclerView.setAdapter(adapter);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ AccessibilityNodeInfo windowRoot = sUiAutomoation.getRootInActiveWindow();
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo scrollable = createNode("scrollable");
+
+ int direction = View.FOCUS_FORWARD;
+
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+ assertThat(target.node).isEqualTo(scrollable);
+
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / | \
+ * / | \
+ * / | \
+ * / | \
+ * button1 non-scrollable button2
+ * recyclerView
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetSkipScrollableContainer() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_skip_scrollable_container_test_1_activity);
+
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ FindRotateTargetResult target = mNavigator.findRotateTarget(button1, direction, 1);
+ assertThat(target.node).isEqualTo(button2);
+
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView scrollable
+ * container
+ * / \
+ * / \
+ * focusable1 focusable2
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetSkipScrollableContainer2() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_skip_scrollable_container_test_2_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView recyclerView = activity.findViewById(R.id.scrollable);
+ recyclerView.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 2);
+ adapter.setItemsFocusable(true);
+ recyclerView.setAdapter(adapter);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ AccessibilityNodeInfo focusable1 = createNodeByText("Test Item 1");
+ AccessibilityNodeInfo focusable2 = createNodeByText("Test Item 2");
+
+ int direction = View.FOCUS_BACKWARD;
+
+ FindRotateTargetResult target = mNavigator.findRotateTarget(focusable2, direction, 2);
+ assertThat(target.node).isEqualTo(focusable1);
+ assertThat(target.advancedCount).isEqualTo(1);
+
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following node tree:
+ * <pre>
+ * node
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetWithOneNode() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_one_node_test_activity);
+
+ AccessibilityNodeInfo node = createNode("node");
+
+ int direction = View.FOCUS_BACKWARD;
+
+ FindRotateTargetResult target = mNavigator.findRotateTarget(node, direction, 1);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following layout:
+ * <pre>
+ * ============ focus area 1 ==========
+ * = =
+ * = ***** scrollable container **** =
+ * = * * =
+ * = * ........ text 1 ........ * =
+ * = * . . * =
+ * = * .......................... * =
+ * = * * =
+ * = * ........ text 2 ........ * =
+ * = * . . * =
+ * = * .......................... * =
+ * = * * =
+ * = ******************************* =
+ * = =
+ * ============ focus area 1 ==========
+ * ============ focus area 2 ==========
+ * = ........ text 3 ........ =
+ * = . . =
+ * = .......................... =
+ * ============ focus area 2 ==========
+ * </pre>
+ */
+ @Test
+ public void testFindRotateTargetInScrollableContainer1() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_in_scrollable_container_test_1_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView recyclerView = activity.findViewById(R.id.scrollable);
+ recyclerView.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 2);
+ adapter.setItemsFocusable(true);
+ recyclerView.setAdapter(adapter);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ AccessibilityNodeInfo text1 = createNodeByText("Test Item 1");
+ AccessibilityNodeInfo text2 = createNodeByText("Test Item 2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate once, the focus should move from text1 to text2.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(text1, direction, 1);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+
+ // Rotate twice, the focus should move from text1 to text2 since text3 is not a
+ // descendant of the scrollable container.
+ target = mNavigator.findRotateTarget(text1, direction, 2);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+
+ // Rotate three times should do the same.
+ target = mNavigator.findRotateTarget(text1, direction, 3);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findRotateTarget} in the following layout:
+ * <pre>
+ * ============ focus area ============
+ * = =
+ * = ***** scrollable container **** =
+ * = * * =
+ * = * ........ text 1 ........ * =
+ * = * . visible . * =
+ * = * .......................... * =
+ * = * * =
+ * = * ........ text 2 ........ * =
+ * = * . visible . * =
+ * = * .......................... * =
+ * = * * =
+ * = * ........ text 3 ........ * =
+ * = * . not visible . * =
+ * = * .......................... * =
+ * = * * =
+ * = ******************************* =
+ * = =
+ * ============ focus area ============
+ * </pre>
+ * where {@code text 3} is off the screen.
+ */
+ @Test
+ public void testFindRotateTargetInScrollableContainer2() {
+ initActivity(
+ R.layout.navigator_find_rotate_target_in_scrollable_container_test_2_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView recyclerView = activity.findViewById(R.id.scrollable);
+ recyclerView.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 3);
+ adapter.setItemsFocusable(true);
+ recyclerView.setAdapter(adapter);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ AccessibilityNodeInfo text1 = createNodeByText("Test Item 1");
+ AccessibilityNodeInfo text2 = createNodeByText("Test Item 2");
+
+ int direction = View.FOCUS_FORWARD;
+
+ // Rotate once, the focus should move from text1 to text2.
+ FindRotateTargetResult target = mNavigator.findRotateTarget(text1, direction, 1);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+
+ // Rotate twice, the focus should move from text1 to text2 since text3 is off the
+ // screen.
+ target = mNavigator.findRotateTarget(text1, direction, 2);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+
+ // Rotate three times should do the same.
+ target = mNavigator.findRotateTarget(text1, direction, 3);
+ assertThat(target.node).isEqualTo(text2);
+ assertThat(target.advancedCount).isEqualTo(1);
+ Utils.recycleNode(target.node);
+ }
+
+ /**
+ * Tests {@link Navigator#findScrollableContainer} in the following node tree:
+ * <pre>
+ * root
+ * |
+ * |
+ * focusArea
+ * / \
+ * / \
+ * scrolling button2
+ * container
+ * |
+ * |
+ * container
+ * |
+ * |
+ * button1
+ * </pre>
+ */
+ @Test
+ public void testFindScrollableContainer() {
+ initActivity(R.layout.navigator_find_scrollable_container_test_activity);
+
+ AccessibilityNodeInfo scrollableContainer = createNode("scrollableContainer");
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+
+ AccessibilityNodeInfo target = mNavigator.findScrollableContainer(button1);
+ assertThat(target).isEqualTo(scrollableContainer);
+ Utils.recycleNodes(target);
+
+ target = mNavigator.findScrollableContainer(button2);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusableDescendantInDirection} going
+ * {@link View#FOCUS_BACKWARD} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * container1 container2
+ * / \ / \
+ * / \ / \
+ * button1 button2 button3 button4
+ * </pre>
+ */
+ @Test
+ public void testFindFocusableVisibleDescendantInDirectionBackward() {
+ initActivity(R.layout.navigator_find_focusable_descendant_test_activity);
+
+ AccessibilityNodeInfo container1 = createNode("container1");
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo container2 = createNode("container2");
+ AccessibilityNodeInfo button3 = createNode("button3");
+ AccessibilityNodeInfo button4 = createNode("button4");
+
+ int direction = View.FOCUS_BACKWARD;
+
+ AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(container2,
+ button4, direction);
+ assertThat(target).isEqualTo(button3);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container2, button3, direction);
+ assertThat(target).isNull();
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container1, button2, direction);
+ assertThat(target).isEqualTo(button1);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container1, button1, direction);
+ assertThat(target).isNull();
+ Utils.recycleNode(target);
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusableDescendantInDirection} going
+ * {@link View#FOCUS_FORWARD} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * container1 container2
+ * / \ / \
+ * / \ / \
+ * button1 button2 button3 button4
+ * </pre>
+ */
+ @Test
+ public void testFindFocusableVisibleDescendantInDirectionForward() {
+ initActivity(R.layout.navigator_find_focusable_descendant_test_activity);
+
+ AccessibilityNodeInfo container1 = createNode("container1");
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo container2 = createNode("container2");
+ AccessibilityNodeInfo button3 = createNode("button3");
+ AccessibilityNodeInfo button4 = createNode("button4");
+
+ int direction = View.FOCUS_FORWARD;
+
+ AccessibilityNodeInfo target = mNavigator.findFocusableDescendantInDirection(container1,
+ button1, direction);
+ assertThat(target).isEqualTo(button2);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container1, button2, direction);
+ assertThat(target).isNull();
+
+ target = mNavigator.findFocusableDescendantInDirection(container2, button3, direction);
+ assertThat(target).isEqualTo(button4);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container2, button4, direction);
+ assertThat(target).isNull();
+ }
+
+ /**
+ * Tests {@link Navigator#findNextFocusableDescendant} in the following node tree:
+ * <pre>
+ * root
+ * |
+ * |
+ * container
+ * / / \ \
+ * / / \ \
+ * button1 button2 button3 button4
+ * </pre>
+ * where {@code button3} and {@code button4} have empty bounds.
+ */
+ @Test
+ public void testFindNextFocusableDescendantWithEmptyBounds() {
+ initActivity(R.layout.navigator_find_focusable_descendant_empty_bounds_test_activity);
+
+ AccessibilityNodeInfo container = createNode("container");
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo button3 = createNode("button3");
+ AccessibilityNodeInfo button4 = createNode("button4");
+
+ int direction = View.FOCUS_FORWARD;
+
+ AccessibilityNodeInfo target =
+ mNavigator.findFocusableDescendantInDirection(container, button1, direction);
+ assertThat(target).isEqualTo(button2);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container, button2, direction);
+ assertThat(target).isEqualTo(button1);
+ Utils.recycleNode(target);
+
+ target = mNavigator.findFocusableDescendantInDirection(container, button3, direction);
+ assertThat(target).isEqualTo(button1);
+ Utils.recycleNode(target);
+
+ // Wrap around since there is no Focus Parking View present.
+ target = mNavigator.findFocusableDescendantInDirection(container, button4, direction);
+ assertThat(target).isEqualTo(button1);
+ Utils.recycleNode(target);
+ }
+
+ /**
+ * Tests {@link Navigator#findFirstFocusableDescendant} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * container1 container2
+ * / \ / \
+ * / \ / \
+ * button1 button2 button3 button4
+ * </pre>
+ * where {@code button1} and {@code button2} are disabled.
+ */
+ @Test
+ public void testFindFirstFocusableDescendant() {
+ initActivity(R.layout.navigator_find_focusable_descendant_test_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ View rootView = activity.findViewById(R.id.root);
+ rootView.post(() -> {
+ Button button1View = activity.findViewById(R.id.button1);
+ button1View.setEnabled(false);
+ Button button2View = activity.findViewById(R.id.button2);
+ button2View.setEnabled(false);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ // When searching for the target node, even though the states of the views are correct
+ // (i.e., button1View and button2View have been disabled), the states of the nodes might
+ // not be up to date (i.e., button1 and button2 haven't been disabled yet) because they're
+ // fetched from the node pool. So new nodes are created here to ensure the nodes are up to
+ // date with the most recent state changes of the views they represent.
+ AccessibilityNodeInfo button1 = createNode("button1");
+ assertThat(button1.isEnabled()).isFalse();
+ AccessibilityNodeInfo button2 = createNode("button2");
+ assertThat(button2.isEnabled()).isFalse();
+
+ AccessibilityNodeInfo root = createNode("root");
+ AccessibilityNodeInfo button3 = createNode("button3");
+
+ AccessibilityNodeInfo target = mNavigator.findFirstFocusableDescendant(root);
+ assertThat(target).isEqualTo(button3);
+ Utils.recycleNode(target);
+ }
+
+ /**
+ * Tests {@link Navigator#findLastFocusableDescendant} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * container1 container2
+ * / \ / \
+ * / \ / \
+ * button1 button2 button3 button4
+ * </pre>
+ * where {@code button3} and {@code button4} are disabled.
+ */
+ @Test
+ public void testFindLastFocusableDescendant() {
+ initActivity(R.layout.navigator_find_focusable_descendant_test_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ View rootView = activity.findViewById(R.id.root);
+ rootView.post(() -> {
+ Button button3View = activity.findViewById(R.id.button3);
+ button3View.setEnabled(false);
+ Button button4View = activity.findViewById(R.id.button4);
+ button4View.setEnabled(false);
+ });
+
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ // When searching for the target node, even though the states of the views are correct
+ // (i.e., button3View and button4View have been disabled), the states of the nodes might
+ // not be up to date (i.e., button3 and button4 haven't been disabled yet) because they're
+ // fetched from the node pool. So new nodes are created here to ensure the nodes are up to
+ // date with the most recent state changes of the views they represent.
+ AccessibilityNodeInfo button3 = createNode("button3");
+ assertThat(button3.isEnabled()).isFalse();
+ AccessibilityNodeInfo button4 = createNode("button4");
+ assertThat(button4.isEnabled()).isFalse();
+
+ AccessibilityNodeInfo root = createNode("root");
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo target = mNavigator.findLastFocusableDescendant(root);
+ assertThat(target).isEqualTo(button2);
+ Utils.recycleNode(target);
+ }
+
+
+ /**
+ * Tests {@link Navigator#findNudgeTargetFocusArea} in the following layout:
+ * <pre>
+ *
+ * =====focusArea1==============
+ * = =========focusArea2==== =
+ * = = *view* = =
+ * = ======================= =
+ * = =
+ * = =
+ * = *scrollableContainer* =
+ * = =
+ * =============================
+ * </pre>
+ * Where scrollableContainer has the same size as focusArea1. The top offset of focusArea1
+ * equals the height of focusArea2.
+ */
+ @Test
+ public void test_findNudgeTargetFocusArea_fromScrollableContainer() {
+ initActivity(R.layout.navigator_find_nudge_target_focus_area_1_test_activity);
+ Activity activity = mActivityRule.getActivity();
+ RecyclerView scrollable = activity.findViewById(R.id.scrollable_container);
+ scrollable.post(() -> {
+ TestRecyclerViewAdapter adapter = new TestRecyclerViewAdapter(activity, 20);
+ scrollable.setAdapter(adapter);
+ scrollable.requestFocus();
+ });
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(scrollable.isFocused()).isEqualTo(true);
+
+ AccessibilityNodeInfo currentFocusArea = createNode("focus_area1");
+
+ // Only an AccessibilityService with the permission to retrieve the active window content
+ // can create an AccessibilityWindowInfo. So the AccessibilityWindowInfo and the associated
+ // AccessibilityNodeInfos have to be mocked.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ AccessibilityNodeInfo sourceNode = mNodeBuilder
+ .setWindow(window)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .setParent(currentFocusArea)
+ .setRotaryContainer()
+ .build();
+ // Though there are 20 children in the layout, only one child is mocked. This is fine as
+ // long as the bounds are correct so that it can occupy the entire container.
+ AccessibilityNodeInfo childNode = mNodeBuilder
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .setParent(sourceNode)
+ .build();
+
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+
+ int direction = View.FOCUS_UP;
+ AccessibilityNodeInfo targetFocusArea = createNode("focus_area2");
+ AccessibilityNodeInfo result = mNavigator.findNudgeTargetFocusArea(
+ windows, sourceNode, currentFocusArea, direction);
+ assertThat(result).isEqualTo(targetFocusArea);
+
+ // Note: only real nodes (and windows) need to be recycled.
+ Utils.recycleNode(result);
+ }
+
+ /**
+ * Tests {@link Navigator#findNudgeTargetFocusArea} in the following layout:
+ * <pre>
+ * ========topLeft focus area======== ========topRight focus area========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = . topLeft1 . . topLeft2 . = = . topRight1 . =
+ * = . . . . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * =======middleLeft focus area======
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .middleLeft1. .middleLeft2. =
+ * = . disabled . . disabled . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * =======bottomLeft focus area======
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .bottomLeft1. .bottomLeft2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testFindNudgeTargetFocusArea2() {
+ initActivity(R.layout.navigator_find_nudge_target_focus_area_2_test_activity);
+
+ // The only way to create a AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(window);
+
+ // Nudge down from topLeft1.
+ AccessibilityNodeInfo topLeftFocusArea = createNode("top_left");
+ AccessibilityNodeInfo bottomLeftFocusArea = createNode("bottom_left");
+ AccessibilityNodeInfo topLeft1 = createNode("top_left1");
+ AccessibilityNodeInfo mockTopLeft1 = mNodeBuilder
+ .setWindow(window)
+ .setBoundsInScreen(topLeft1.getBoundsInScreen())
+ .setParent(topLeftFocusArea)
+ .build();
+ AccessibilityNodeInfo target1 = mNavigator.findNudgeTargetFocusArea(
+ windows, mockTopLeft1, topLeftFocusArea, View.FOCUS_DOWN);
+ assertThat(target1).isEqualTo(bottomLeftFocusArea);
+
+ // Reach to the boundary.
+ AccessibilityNodeInfo bottomLeft1 = createNode("bottom_left1");
+ AccessibilityNodeInfo mockBottomLeft1 = mNodeBuilder
+ .setWindow(window)
+ .setBoundsInScreen(bottomLeft1.getBoundsInScreen())
+ .setParent(bottomLeftFocusArea)
+ .build();
+ AccessibilityNodeInfo target2 = mNavigator.findNudgeTargetFocusArea(
+ windows, mockBottomLeft1, bottomLeftFocusArea, View.FOCUS_DOWN);
+ assertThat(target2).isNull();
+
+ // Nudge to the right.
+ AccessibilityNodeInfo topRightFocusArea = createNode("top_right");
+ AccessibilityNodeInfo target3 = mNavigator.findNudgeTargetFocusArea(
+ windows, mockBottomLeft1, bottomLeftFocusArea, View.FOCUS_RIGHT);
+ assertThat(target3).isEqualTo(topRightFocusArea);
+
+ Utils.recycleNodes(target1, target2, target3);
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusParkingView} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * parent button
+ * |
+ * |
+ * focusParkingView
+ * </pre>
+ */
+ @Test
+ public void testFindFocusParkingView() {
+ initActivity(R.layout.navigator_find_focus_parking_view_test_activity);
+
+ // The only way to create a AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder().setRoot(mWindowRoot).build();
+ AccessibilityNodeInfo button = mNodeBuilder.setWindow(window).build();
+
+ AccessibilityNodeInfo fpv = createNode("focusParkingView");
+ AccessibilityNodeInfo result = mNavigator.findFocusParkingView(button);
+ assertThat(result).isEqualTo(fpv);
+ Utils.recycleNode(result);
+ }
+
+ @Test
+ public void testfindHunWindow() {
+ // The only way to create a AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo hunWindow = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
+ .setBoundsInScreen(mHunWindowBounds)
+ .build();
+ AccessibilityWindowInfo window2 = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
+ .setBoundsInScreen(mHunWindowBounds)
+ .build();
+ AccessibilityWindowInfo window3 = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(window2);
+ windows.add(window3);
+ windows.add(hunWindow);
+
+ AccessibilityWindowInfo result = mNavigator.findHunWindow(windows);
+ assertThat(result).isEqualTo(hunWindow);
+ }
+
+ @Test
+ public void testIsHunWindow() {
+ // The only way to create an AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
+ .setBoundsInScreen(mHunWindowBounds)
+ .build();
+ boolean isHunWindow = mNavigator.isHunWindow(window);
+ assertThat(isHunWindow).isTrue();
+ }
+
+ @Test
+ public void testIsMainApplicationWindow_returnsTrue() {
+ // The only way to create an AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
+ .setBoundsInScreen(mDisplayBounds)
+ .setDisplayId(Display.DEFAULT_DISPLAY)
+ .build();
+ boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
+ assertThat(isMainApplicationWindow).isTrue();
+ }
+
+ @Test
+ public void testIsMainApplicationWindow_wrongDisplay_returnsFalse() {
+ // The only way to create an AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
+ .setBoundsInScreen(mDisplayBounds)
+ .setDisplayId(1)
+ .build();
+ boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
+ assertThat(isMainApplicationWindow).isFalse();
+ }
+
+ @Test
+ public void testIsMainApplicationWindow_wrongType_returnsFalse() {
+ // The only way to create an AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
+ .setBoundsInScreen(mDisplayBounds)
+ .setDisplayId(Display.DEFAULT_DISPLAY)
+ .build();
+ boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
+ assertThat(isMainApplicationWindow).isFalse();
+ }
+
+ @Test
+ public void testIsMainApplicationWindow_wrongBounds_returnsFalse() {
+ // The only way to create an AccessibilityWindowInfo in the test is via mock.
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
+ .setBoundsInScreen(mHunWindowBounds)
+ .setDisplayId(Display.DEFAULT_DISPLAY)
+ .build();
+ boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
+ assertThat(isMainApplicationWindow).isFalse();
+ }
+
+ /**
+ * Tests {@link Navigator#getAncestorFocusArea} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusArea button3
+ * / \
+ * / \
+ * parent button2
+ * |
+ * |
+ * button1
+ * </pre>
+ */
+ @Test
+ public void testGetAncestorFocusArea() {
+ initActivity(R.layout.navigator_get_ancestor_focus_area_test_activity);
+
+ AccessibilityNodeInfo focusArea = createNode("focusArea");
+ AccessibilityNodeInfo button1 = createNode("button1");
+ AccessibilityNodeInfo result1 = mNavigator.getAncestorFocusArea(button1);
+ assertThat(result1).isEqualTo(focusArea);
+
+ AccessibilityNodeInfo button2 = createNode("button2");
+ AccessibilityNodeInfo result2 = mNavigator.getAncestorFocusArea(button2);
+ assertThat(result2).isEqualTo(focusArea);
+
+ AccessibilityNodeInfo button3 = createNode("button3");
+ AccessibilityNodeInfo result3 = mNavigator.getAncestorFocusArea(button3);
+ assertThat(result3).isEqualTo(mWindowRoot);
+
+ Utils.recycleNodes(result1, result2, result3);
+ }
+
+ /**
+ * Tests {@link Navigator#getRoot} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testGetRoot_returnHostAppRoot() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ assertThat(mNavigator.getRoot(button2)).isEqualTo(hostAppRoot);
+ }
+
+ /**
+ * Tests {@link Navigator#getRoot} in the following node tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * button1 viewGroup1
+ * |
+ * viewGroup2
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testGetRoot_returnNodeRoot() {
+ AccessibilityNodeInfo root = mNodeBuilder.build();
+ AccessibilityNodeInfo button1 = mNodeBuilder.setParent(root).build();
+ AccessibilityNodeInfo viewGroup1 = mNodeBuilder.setParent(root).build();
+ AccessibilityNodeInfo viewGroup2 = mNodeBuilder.setParent(viewGroup1).build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(viewGroup2)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder.setParent(viewGroup2).build();
+
+ assertThat(mNavigator.getRoot(button2)).isEqualTo(root);
+ }
+
+ /**
+ * Tests {@link Navigator#getRoot} in the window:
+ * <pre>
+ * windowRoot
+ * ...
+ * button
+ * </pre>
+ * where {@code windowRoot} is the root node of the window containing {@code button}.
+ */
+ @Test
+ public void testGetRoot_returnWindowRoot() {
+ AccessibilityNodeInfo root = mNodeBuilder.build();
+ AccessibilityWindowInfo window = new WindowBuilder().setRoot(root).build();
+ AccessibilityNodeInfo button = mNodeBuilder.setWindow(window).build();
+ assertThat(mNavigator.getRoot(button)).isEqualTo(root);
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusParkingViewInRoot} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testFindFocusParkingViewInRoot() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ assertThat(mNavigator.findFocusParkingViewInRoot(clientAppRoot))
+ .isEqualTo(focusParkingView);
+ }
+
+ /**
+ * Tests {@link Navigator#findSurfaceViewInRoot} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testFindSurfaceViewInRoot() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ assertThat(mNavigator.findSurfaceViewInRoot(clientAppRoot)).isEqualTo(surfaceView);
+ }
+
+ /**
+ * Tests {@link Navigator#getDescendantHostRoot} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testGetDescendantHostRoot() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ assertThat(mNavigator.getDescendantHostRoot(clientAppRoot)).isEqualTo(hostAppRoot);
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusAreas} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2
+ * </pre>
+ */
+ @Test
+ public void testFindFocusAreas_inHostApp() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ AccessibilityWindowInfo window = new WindowBuilder().setRoot(clientAppRoot).build();
+
+ assertThat(mNavigator.findFocusAreas(window)).containsExactly(hostAppRoot);
+ }
+
+ /**
+ * Tests {@link Navigator#findFocusedNodeInRoot} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView(focused)
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2(focused)
+ * </pre>
+ */
+ @Test
+ public void testFindFocusedNodeInRoot_inHostApp() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setFocused(true)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setFocused(true)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ assertThat(mNavigator.findFocusedNodeInRoot(clientAppRoot)).isEqualTo(button2);
+ }
+
+ /**
+ * Starts the test activity with the given layout and initializes the root
+ * {@link AccessibilityNodeInfo}.
+ */
+ private void initActivity(@LayoutRes int layoutResId) {
+ mIntent.putExtra(NavigatorTestActivity.KEY_LAYOUT_ID, layoutResId);
+ mActivityRule.launchActivity(mIntent);
+
+ mWindowRoot = sUiAutomoation.getRootInActiveWindow();
+ }
+
+ /**
+ * Returns the {@link AccessibilityNodeInfo} related to the provided viewId. Returns null if no
+ * such node exists. Callers should ensure {@link #initActivity} has already been called. Caller
+ * shouldn't recycle the result because it will be recycled in {@link #tearDown}.
+ */
+ private AccessibilityNodeInfo createNode(String viewId) {
+ String fullViewId = "com.android.car.rotary.tests.unit:id/" + viewId;
+ List<AccessibilityNodeInfo> nodes =
+ mWindowRoot.findAccessibilityNodeInfosByViewId(fullViewId);
+ if (nodes.isEmpty()) {
+ L.e("Failed to create node by View ID " + viewId);
+ return null;
+ }
+ mNodes.addAll(nodes);
+ return nodes.get(0);
+ }
+
+ /**
+ * Returns the {@link AccessibilityNodeInfo} of the view containing the provided text. Returns
+ * null if no such node exists. Callers should ensure {@link #initActivity} has already
+ * been called and also recycle the result.
+ */
+ private AccessibilityNodeInfo createNodeByText(String text) {
+ List<AccessibilityNodeInfo> nodes = mWindowRoot.findAccessibilityNodeInfosByText(text);
+ if (nodes.isEmpty()) {
+ L.e("Failed to create node by text '" + text + "'");
+ return null;
+ }
+ return nodes.get(0);
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/NavigatorTestActivity.java b/tests/unit/src/com/android/car/rotary/NavigatorTestActivity.java
new file mode 100644
index 0000000..1d58a3c
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/NavigatorTestActivity.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.annotation.LayoutRes;
+import androidx.annotation.Nullable;
+
+/** An activity used for testing {@link com.android.car.rotary.Navigator}. */
+public class NavigatorTestActivity extends Activity {
+
+ /** Key used to indicate which resource layout to inflate. */
+ static final String KEY_LAYOUT_ID = "com.android.car.rotary.KEY_LAYOUT_ID";
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Intent intent = getIntent();
+ if (intent != null) {
+ @LayoutRes int layoutId = intent.getIntExtra(KEY_LAYOUT_ID, /* defaultValue= */ -1);
+ if (layoutId != -1) {
+ setContentView(layoutId);
+ }
+ }
+ }
+}
diff --git a/tests/robotests/src/com/android/car/rotary/NodeBuilder.java b/tests/unit/src/com/android/car/rotary/NodeBuilder.java
index 09b26b2..1578aba 100644
--- a/tests/robotests/src/com/android/car/rotary/NodeBuilder.java
+++ b/tests/unit/src/com/android/car/rotary/NodeBuilder.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 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.
@@ -15,6 +15,7 @@
*/
package com.android.car.rotary;
+import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
import static android.view.accessibility.AccessibilityWindowInfo.UNDEFINED_WINDOW_ID;
import static com.android.car.rotary.Utils.FOCUS_AREA_CLASS_NAME;
@@ -24,12 +25,14 @@ import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_O
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET;
+import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import static org.mockito.internal.util.MockUtil.isMock;
import android.graphics.Rect;
import android.os.Bundle;
@@ -38,6 +41,7 @@ import android.view.accessibility.AccessibilityWindowInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
@@ -48,16 +52,14 @@ import java.util.List;
* need to be recycled.
*/
class NodeBuilder {
-
- private static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 100, 100);
-
+ @VisibleForTesting
+ static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 100, 100);
/**
* A list of mock nodes created via NodeBuilder. This list is used for searching for a
* node's child nodes.
*/
@NonNull
private final List<AccessibilityNodeInfo> mNodeList;
-
/** The window to which this node belongs. */
@Nullable
private AccessibilityWindowInfo mWindow;
@@ -66,6 +68,9 @@ class NodeBuilder {
/** The parent of this node. */
@Nullable
private AccessibilityNodeInfo mParent;
+ /** The package this node comes from. */
+ @Nullable
+ private String mPackageName;
/** The class this node comes from. */
@Nullable
private String mClassName;
@@ -77,6 +82,8 @@ class NodeBuilder {
private Rect mBoundsInScreen = new Rect(DEFAULT_BOUNDS);
/** Whether this node is focusable. */
private boolean mFocusable = true;
+ /** Whether this node is focused. */
+ private boolean mFocused = false;
/** Whether this node is visible to the user. */
private boolean mVisibleToUser = true;
/** Whether this node is enabled. */
@@ -88,6 +95,9 @@ class NodeBuilder {
/** The content description for this node. */
@Nullable
private String mContentDescription;
+ /** The state description for this node. */
+ @Nullable
+ private String mStateDescription;
/** The action list for this node. */
@NonNull
private List<AccessibilityNodeInfo.AccessibilityAction> mActionList = new ArrayList<>();
@@ -102,14 +112,33 @@ class NodeBuilder {
AccessibilityNodeInfo build() {
// Make a copy of the current NodeBuilder.
NodeBuilder builder = cut();
-
AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class);
-
when(node.getWindow()).thenReturn(builder.mWindow);
when(node.getWindowId()).thenReturn(builder.mWindowId);
- when(node.getParent()).thenReturn(builder.mParent);
-
- if (builder.mParent != null) {
+ when(node.getParent()).thenReturn(
+ // In Navigator, nodes will be recycled once they're no longer used. When a real
+ // node is recycled, it can't be used again, such as performing an action, otherwise
+ // it will cause the "Cannot perform this action on a not sealed instance"
+ // exception. If the mock getParent() always returns the same instance and
+ // mParent is a real node, it might cause the exception when getParent() is called
+ // multiple on the same mock node.
+ // To fix it, this method returns a different instance each time it's called.
+ // Note: if this method is called too many times and triggers the "Exceeded the
+ // maximum calls", please add more parameters.
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent),
+ MockNodeCopierProvider.get().copy(builder.mParent))
+ .thenThrow(new RuntimeException("Exceeded the maximum calls"));
+ // There is no need to mock getChildCount() or getChild() if mParent is null or a real node.
+ if (builder.mParent != null && isMock(builder.mParent)) {
// Mock AccessibilityNodeInfo#getChildCount().
doAnswer(invocation -> {
int childCount = 0;
@@ -120,7 +149,6 @@ class NodeBuilder {
}
return childCount;
}).when(builder.mParent).getChildCount();
-
// Mock AccessibilityNodeInfo#getChild(int).
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
@@ -137,9 +165,8 @@ class NodeBuilder {
return null;
}).when(builder.mParent).getChild(any(Integer.class));
}
-
+ when(node.getPackageName()).thenReturn(builder.mPackageName);
when(node.getClassName()).thenReturn(builder.mClassName);
-
doAnswer(invocation -> {
Object[] args = invocation.getArguments();
((Rect) args[0]).set(builder.mBoundsInParent);
@@ -150,16 +177,30 @@ class NodeBuilder {
((Rect) args[0]).set(builder.mBoundsInScreen);
return null;
}).when(node).getBoundsInScreen(any(Rect.class));
-
+ when(node.getBoundsInScreen()).thenReturn(builder.mBoundsInScreen);
when(node.isFocusable()).thenReturn(builder.mFocusable);
+ when(node.isFocused()).thenReturn(builder.mFocused);
+ doAnswer(invocation -> {
+ if (node.isFocused()) {
+ return node;
+ }
+ for (int i = 0; i < node.getChildCount(); i++) {
+ AccessibilityNodeInfo child = node.getChild(i);
+ AccessibilityNodeInfo inputFocus = child.findFocus(FOCUS_INPUT);
+ if (inputFocus != null) {
+ return inputFocus;
+ }
+ }
+ return null;
+ }).when(node).findFocus(FOCUS_INPUT);
when(node.isVisibleToUser()).thenReturn(builder.mVisibleToUser);
when(node.isEnabled()).thenReturn(builder.mEnabled);
when(node.refresh()).thenReturn(builder.mInViewTree);
when(node.isScrollable()).thenReturn(builder.mScrollable);
when(node.getContentDescription()).thenReturn(builder.mContentDescription);
+ when(node.getStateDescription()).thenReturn(builder.mStateDescription);
when(node.getActionList()).thenReturn(builder.mActionList);
when(node.getExtras()).thenReturn(builder.mExtras);
-
builder.mNodeList.add(node);
return node;
}
@@ -175,7 +216,14 @@ class NodeBuilder {
}
NodeBuilder setParent(@Nullable AccessibilityNodeInfo parent) {
- mParent = parent;
+ // The parent node could be a mock node or a real node. In the latter case, a copy is
+ // saved.
+ mParent = MockNodeCopierProvider.get().copy(parent);
+ return this;
+ }
+
+ NodeBuilder setPackageName(@Nullable String packageName) {
+ mPackageName = packageName;
return this;
}
@@ -199,6 +247,11 @@ class NodeBuilder {
return this;
}
+ NodeBuilder setFocused(boolean focused) {
+ mFocused = focused;
+ return this;
+ }
+
NodeBuilder setVisibleToUser(boolean visibleToUser) {
mVisibleToUser = visibleToUser;
return this;
@@ -224,6 +277,11 @@ class NodeBuilder {
return this;
}
+ NodeBuilder setStateDescription(@Nullable String stateDescription) {
+ mStateDescription = stateDescription;
+ return this;
+ }
+
NodeBuilder setActions(AccessibilityNodeInfo.AccessibilityAction... actions) {
mActionList = Arrays.asList(actions);
return this;
@@ -253,6 +311,10 @@ class NodeBuilder {
return setContentDescription(ROTARY_VERTICALLY_SCROLLABLE);
}
+ NodeBuilder setRotaryContainer() {
+ return setContentDescription(ROTARY_CONTAINER);
+ }
+
/**
* Creates a copy of the current NodeBuilder, and clears the states of the current NodeBuilder
* except for {@link #mNodeList}.
@@ -263,34 +325,38 @@ class NodeBuilder {
copy.mWindow = mWindow;
copy.mWindowId = mWindowId;
copy.mParent = mParent;
+ copy.mPackageName = mPackageName;
copy.mClassName = mClassName;
copy.mBoundsInParent = mBoundsInParent;
copy.mBoundsInScreen = mBoundsInScreen;
copy.mFocusable = mFocusable;
+ copy.mFocused = mFocused;
copy.mVisibleToUser = mVisibleToUser;
copy.mEnabled = mEnabled;
copy.mInViewTree = mInViewTree;
copy.mScrollable = mScrollable;
copy.mContentDescription = mContentDescription;
+ copy.mStateDescription = mStateDescription;
copy.mActionList = mActionList;
copy.mExtras = mExtras;
-
// Clear the states so that it doesn't infect the next NodeBuilder we create.
mWindow = null;
mWindowId = UNDEFINED_WINDOW_ID;
mParent = null;
+ mPackageName = null;
mClassName = null;
mBoundsInParent = new Rect(DEFAULT_BOUNDS);
mBoundsInScreen = new Rect(DEFAULT_BOUNDS);
mFocusable = true;
+ mFocused = false;
mVisibleToUser = true;
mEnabled = true;
mInViewTree = true;
mScrollable = false;
mContentDescription = null;
+ mStateDescription = null;
mActionList = new ArrayList<>();
mExtras = new Bundle();
-
return copy;
}
}
diff --git a/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java b/tests/unit/src/com/android/car/rotary/NodeBuilderTest.java
index 75bcb2b..0314646 100644
--- a/tests/robotests/src/com/android/car/rotary/NodeBuilderTest.java
+++ b/tests/unit/src/com/android/car/rotary/NodeBuilderTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 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.
@@ -16,6 +16,7 @@
package com.android.car.rotary;
import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
+import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT;
import static com.android.car.rotary.Utils.FOCUS_AREA_CLASS_NAME;
import static com.android.car.rotary.Utils.FOCUS_PARKING_VIEW_CLASS_NAME;
@@ -24,6 +25,7 @@ import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_O
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET;
+import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
import static com.google.common.truth.Truth.assertThat;
@@ -33,19 +35,20 @@ import android.os.Bundle;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
import java.util.ArrayList;
-@RunWith(RobolectricTestRunner.class)
+@RunWith(AndroidJUnit4.class)
public class NodeBuilderTest {
-
+ private static final String PACKAGE_NAME = "package_name";
private static final String CLASS_NAME = "class_name";
private static final String CONTENT_DESCRIPTION = "content_description";
-
+ private static final String STATE_DESCRIPTION = "state_description";
private NodeBuilder mNodeBuilder;
@Before
@@ -57,6 +60,7 @@ public class NodeBuilderTest {
public void testBuildDefaultNode() {
AccessibilityNodeInfo node = mNodeBuilder.build();
assertThat(node.isFocusable()).isTrue();
+ assertThat(node.isFocused()).isFalse();
assertThat(node.isVisibleToUser()).isTrue();
assertThat(node.refresh()).isTrue();
assertThat(node.isEnabled()).isTrue();
@@ -66,7 +70,8 @@ public class NodeBuilderTest {
assertThat(boundsInParent.isEmpty()).isFalse();
Rect boundsInScreen = new Rect();
node.getBoundsInScreen(boundsInScreen);
- assertThat(boundsInScreen.isEmpty()).isFalse();
+ assertThat(boundsInScreen).isEqualTo(NodeBuilder.DEFAULT_BOUNDS);
+ assertThat(node.getBoundsInScreen()).isEqualTo(NodeBuilder.DEFAULT_BOUNDS);
}
@Test
@@ -76,6 +81,12 @@ public class NodeBuilderTest {
}
@Test
+ public void testSetFocused() {
+ AccessibilityNodeInfo node = mNodeBuilder.setFocused(true).build();
+ assertThat(node.isFocused()).isTrue();
+ }
+
+ @Test
public void testSetVisibleToUser() {
AccessibilityNodeInfo node = mNodeBuilder.setVisibleToUser(false).build();
assertThat(node.isVisibleToUser()).isFalse();
@@ -87,7 +98,6 @@ public class NodeBuilderTest {
assertThat(node.refresh()).isFalse();
}
-
@Test
public void testSetScrollable() {
AccessibilityNodeInfo node = mNodeBuilder.setScrollable(true).build();
@@ -104,7 +114,7 @@ public class NodeBuilderTest {
public void testSetWindow() {
AccessibilityWindowInfo window = new WindowBuilder().build();
AccessibilityNodeInfo node = mNodeBuilder.setWindow(window).build();
- assertThat(node.getWindow()).isSameInstanceAs(window);
+ assertThat(node.getWindow()).isEqualTo(window);
}
@Test
@@ -126,6 +136,12 @@ public class NodeBuilderTest {
}
@Test
+ public void testSetPackageName() {
+ AccessibilityNodeInfo node = mNodeBuilder.setPackageName(PACKAGE_NAME).build();
+ assertThat(node.getPackageName().toString()).isEqualTo(PACKAGE_NAME);
+ }
+
+ @Test
public void testSetClassName() {
AccessibilityNodeInfo node = mNodeBuilder.setClassName(CLASS_NAME).build();
assertThat(node.getClassName().toString()).isEqualTo(CLASS_NAME);
@@ -139,19 +155,45 @@ public class NodeBuilderTest {
}
@Test
+ public void testSetStateDescription() {
+ AccessibilityNodeInfo node =
+ mNodeBuilder.setStateDescription(STATE_DESCRIPTION).build();
+ assertThat(node.getStateDescription().toString()).isEqualTo(STATE_DESCRIPTION);
+ }
+
+ @Test
public void testSetParent() {
AccessibilityNodeInfo parent = mNodeBuilder.build();
AccessibilityNodeInfo child1 = mNodeBuilder.setParent(parent).build();
AccessibilityNodeInfo child2 = mNodeBuilder.setParent(parent).build();
-
- assertThat(child1.getParent()).isSameInstanceAs(parent);
+ assertThat(child1.getParent()).isEqualTo(parent);
assertThat(parent.getChildCount()).isEqualTo(2);
- assertThat(parent.getChild(0)).isSameInstanceAs(child1);
- assertThat(parent.getChild(1)).isSameInstanceAs(child2);
+ assertThat(parent.getChild(0)).isEqualTo(child1);
+ assertThat(parent.getChild(1)).isEqualTo(child2);
assertThat(parent.getChild(2)).isNull();
}
@Test
+ public void testFindInputFocus_succeeded() {
+ AccessibilityNodeInfo root = mNodeBuilder.build();
+ AccessibilityNodeInfo parent1 = mNodeBuilder.setParent(root).build();
+ AccessibilityNodeInfo parent2 = mNodeBuilder.setParent(root).build();
+ AccessibilityNodeInfo child1 = mNodeBuilder.setParent(parent1).build();
+ AccessibilityNodeInfo child2 = mNodeBuilder.setParent(parent1).build();
+ AccessibilityNodeInfo child3 = mNodeBuilder.setParent(parent2).setFocused(true).build();
+ AccessibilityNodeInfo child4 = mNodeBuilder.setParent(parent2).build();
+
+ assertThat(root.findFocus(FOCUS_INPUT)).isEqualTo(child3);
+ }
+
+ @Test
+ public void testFindInputFocus_failed() {
+ AccessibilityNodeInfo parent = mNodeBuilder.build();
+ AccessibilityNodeInfo child = mNodeBuilder.setParent(parent).build();
+ assertThat(parent.findFocus(FOCUS_INPUT)).isNull();
+ }
+
+ @Test
public void testSetActions() {
AccessibilityNodeInfo node = mNodeBuilder.setActions(ACTION_SCROLL_FORWARD).build();
assertThat(node.getActionList()).containsExactly(ACTION_SCROLL_FORWARD);
@@ -197,4 +239,10 @@ public class NodeBuilderTest {
AccessibilityNodeInfo node = mNodeBuilder.setScrollableContainer().build();
assertThat(node.getContentDescription().toString()).isEqualTo(ROTARY_VERTICALLY_SCROLLABLE);
}
+
+ @Test
+ public void testSetRotaryContainer() {
+ AccessibilityNodeInfo node = mNodeBuilder.setRotaryContainer().build();
+ assertThat(node.getContentDescription().toString()).isEqualTo(ROTARY_CONTAINER);
+ }
}
diff --git a/tests/unit/src/com/android/car/rotary/RotaryServiceTest.java b/tests/unit/src/com/android/car/rotary/RotaryServiceTest.java
new file mode 100644
index 0000000..2022600
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/RotaryServiceTest.java
@@ -0,0 +1,1953 @@
+/*
+ * Copyright (C) 2021 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.android.car.rotary;
+
+import static android.view.Display.DEFAULT_DISPLAY;
+import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED;
+import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED;
+import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED;
+import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED;
+import static android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED;
+import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
+
+import static com.android.car.ui.utils.DirectManipulationHelper.DIRECT_MANIPULATION;
+import static com.android.car.ui.utils.RotaryConstants.ACTION_RESTORE_DEFAULT_FOCUS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.AssertJUnit.assertNull;
+
+import android.app.Activity;
+import android.app.UiAutomation;
+import android.car.input.CarInputManager;
+import android.car.input.RotaryEvent;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.hardware.input.InputManager;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import android.widget.Button;
+
+import androidx.annotation.LayoutRes;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+
+import com.android.car.ui.FocusParkingView;
+import com.android.car.ui.utils.DirectManipulationHelper;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class RotaryServiceTest {
+
+ private final static String HOST_APP_PACKAGE_NAME = "host.app.package.name";
+ private final static String CLIENT_APP_PACKAGE_NAME = "client.app.package.name";
+
+ private static UiAutomation sUiAutomoation;
+
+ private final List<AccessibilityNodeInfo> mNodes = new ArrayList<>();
+
+ private AccessibilityNodeInfo mWindowRoot;
+ private ActivityTestRule<NavigatorTestActivity> mActivityRule;
+ private Intent mIntent;
+ private NodeBuilder mNodeBuilder;
+
+ private @Spy
+ RotaryService mRotaryService;
+ private @Spy
+ Navigator mNavigator;
+
+ @BeforeClass
+ public static void setUpClass() {
+ sUiAutomoation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+ }
+
+ @Before
+ public void setUp() {
+ mActivityRule = new ActivityTestRule<>(NavigatorTestActivity.class);
+ mIntent = new Intent();
+ mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ MockitoAnnotations.initMocks(this);
+ mRotaryService.setNavigator(mNavigator);
+ mRotaryService.setNodeCopier(MockNodeCopierProvider.get());
+ mRotaryService.setInputManager(mock(InputManager.class));
+ mNodeBuilder = new NodeBuilder(new ArrayList<>());
+ }
+
+ @After
+ public void tearDown() {
+ mActivityRule.finishActivity();
+ Utils.recycleNode(mWindowRoot);
+ Utils.recycleNodes(mNodes);
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * / | \
+ * / | \
+ * button1 defaultFocus button3
+ * (focused)
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is already set to defaultFocus.
+ */
+ @Test
+ public void testInitFocus_alreadyInitialized() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ AccessibilityNodeInfo defaultFocusNode = createNode("defaultFocus");
+ assertThat(defaultFocusNode.isFocused()).isTrue();
+ mRotaryService.setFocusedNode(defaultFocusNode);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+
+ boolean consumed = mRotaryService.initFocus();
+ assertThat(consumed).isFalse();
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * / | \
+ * / | \
+ * button1 defaultFocus button3
+ * (focused)
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is not initialized.
+ */
+ @Test
+ public void testInitFocus_focusOnAlreadyFocusedView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ Activity activity = mActivityRule.getActivity();
+ Button button3 = activity.findViewById(R.id.button3);
+ button3.post(() -> button3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(button3.isFocused()).isTrue();
+ assertNull(mRotaryService.getFocusedNode());
+
+ boolean consumed = mRotaryService.initFocus();
+ AccessibilityNodeInfo button3Node = createNode("button3");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button3Node);
+ assertThat(consumed).isFalse();
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * (focused) / | \
+ * / | \
+ * button1 defaultFocus button3
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is null.
+ */
+ @Test
+ public void testInitFocus_focusOnDefaultFocusView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ // Move focus to the FocusParkingView.
+ Activity activity = mActivityRule.getActivity();
+ FocusParkingView fpv = activity.findViewById(R.id.focusParkingView);
+ fpv.setShouldRestoreFocus(false);
+ fpv.post(() -> fpv.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(fpv.isFocused()).isTrue();
+ assertNull(mRotaryService.getFocusedNode());
+
+ boolean consumed = mRotaryService.initFocus();
+ AccessibilityNodeInfo defaultFocusNode = createNode("defaultFocus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+ assertThat(consumed).isTrue();
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * (focused) / | \
+ * / | \
+ * button1 defaultFocus button3
+ * (disabled) (last touched)
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is null.
+ */
+ @Test
+ public void testInitFocus_focusOnLastTouchedView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ // The user touches button3. In reality it should enter touch mode therefore no view will
+ // be focused. To emulate this case, this test just moves focus to the FocusParkingView
+ // and sets last touched node to button3.
+ Activity activity = mActivityRule.getActivity();
+ FocusParkingView fpv = activity.findViewById(R.id.focusParkingView);
+ fpv.setShouldRestoreFocus(false);
+ fpv.post(fpv::requestFocus);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(fpv.isFocused()).isTrue();
+ AccessibilityNodeInfo button3Node = createNode("button3");
+ mRotaryService.setLastTouchedNode(button3Node);
+ assertNull(mRotaryService.getFocusedNode());
+
+ boolean consumed = mRotaryService.initFocus();
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button3Node);
+ assertThat(consumed).isTrue();
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * (focused) / | \
+ * / | \
+ * button1 defaultFocus button3
+ * (disabled)
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is null.
+ */
+ @Test
+ public void testInitFocus_focusOnFirstFocusableView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ // Move focus to the FocusParkingView and disable the default focus view.
+ Activity activity = mActivityRule.getActivity();
+ FocusParkingView fpv = activity.findViewById(R.id.focusParkingView);
+ Button defaultFocus = activity.findViewById(R.id.defaultFocus);
+ fpv.setShouldRestoreFocus(false);
+ fpv.post(() -> {
+ fpv.requestFocus();
+ defaultFocus.setEnabled(false);
+
+ });
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(fpv.isFocused()).isTrue();
+ assertThat(defaultFocus.isEnabled()).isFalse();
+ assertNull(mRotaryService.getFocusedNode());
+
+ boolean consumed = mRotaryService.initFocus();
+ AccessibilityNodeInfo button1Node = createNode("button1");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button1Node);
+ assertThat(consumed).isTrue();
+ }
+
+ /**
+ * Tests {@link RotaryService#initFocus()} in the following node tree:
+ * <pre>
+ * clientAppRoot
+ * / \
+ * / \
+ * button1 surfaceView(focused)
+ * |
+ * hostAppRoot
+ * / \
+ * / \
+ * focusParkingView button2(focused)
+ * </pre>
+ * and {@link RotaryService#mFocusedNode} is null.
+ */
+ @Test
+ public void testInitFocus_focusOnHostNode() {
+ mNavigator.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ mNavigator.mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo clientAppRoot = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo button1 = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo surfaceView = mNodeBuilder
+ .setParent(clientAppRoot)
+ .setFocused(true)
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .setClassName(Utils.SURFACE_VIEW_CLASS_NAME)
+ .build();
+
+ AccessibilityNodeInfo hostAppRoot = mNodeBuilder
+ .setParent(surfaceView)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+ AccessibilityNodeInfo focusParkingView = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .setFpv()
+ .build();
+ AccessibilityNodeInfo button2 = mNodeBuilder
+ .setParent(hostAppRoot)
+ .setFocused(true)
+ .setPackageName(HOST_APP_PACKAGE_NAME)
+ .build();
+
+ AccessibilityWindowInfo window = new WindowBuilder().setRoot(clientAppRoot).build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ boolean consumed = mRotaryService.initFocus();
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button2);
+ assertThat(consumed).isFalse();
+ }
+
+ /**
+ * Tests {@link RotaryService#onRotaryEvents} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * (focused) / | \
+ * / | \
+ * button1 defaultFocus button3
+ * </pre>
+ */
+ @Test
+ public void testOnRotaryEvents_withoutFocusedView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ // Move focus to the FocusParkingView.
+ Activity activity = mActivityRule.getActivity();
+ FocusParkingView fpv = activity.findViewById(R.id.focusParkingView);
+ fpv.setShouldRestoreFocus(false);
+ fpv.post(() -> fpv.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(fpv.isFocused()).isTrue();
+ assertNull(mRotaryService.getFocusedNode());
+
+ // Since there is no non-FocusParkingView focused, rotating the controller should
+ // initialize the focus.
+
+ int inputType = CarInputManager.INPUT_TYPE_ROTARY_NAVIGATION;
+ boolean clockwise = true;
+ long[] timestamps = new long[]{0};
+ RotaryEvent rotaryEvent = new RotaryEvent(inputType, clockwise, timestamps);
+ List<RotaryEvent> events = Collections.singletonList(rotaryEvent);
+
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ mRotaryService.onRotaryEvents(validDisplayId, events);
+
+ AccessibilityNodeInfo defaultFocusNode = createNode("defaultFocus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#onRotaryEvents} in the following view tree:
+ * <pre>
+ * root
+ * / \
+ * / \
+ * focusParkingView focusArea
+ * / | \
+ * / | \
+ * button1 defaultFocus button3
+ * (focused)
+ * </pre>
+ */
+ @Test
+ public void testOnRotaryEvents_withFocusedView() {
+ initActivity(R.layout.rotary_service_test_1_activity);
+
+ AccessibilityWindowInfo window = new WindowBuilder()
+ .setRoot(mWindowRoot)
+ .setBoundsInScreen(mWindowRoot.getBoundsInScreen())
+ .build();
+ List<AccessibilityWindowInfo> windows = Collections.singletonList(window);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ doAnswer(invocation -> 1)
+ .when(mRotaryService).getRotateAcceleration(any(Integer.class), any(Long.class));
+
+ AccessibilityNodeInfo defaultFocusNode = createNode("defaultFocus");
+ assertThat(defaultFocusNode.isFocused()).isTrue();
+ mRotaryService.setFocusedNode(defaultFocusNode);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+
+ // Since RotaryService#mFocusedNode is already initialized, rotating the controller
+ // clockwise should move the focus from defaultFocus to button3.
+
+ int inputType = CarInputManager.INPUT_TYPE_ROTARY_NAVIGATION;
+ boolean clockwise = true;
+ long[] timestamps = new long[]{0};
+ RotaryEvent rotaryEvent = new RotaryEvent(inputType, clockwise, timestamps);
+ List<RotaryEvent> events = Collections.singletonList(rotaryEvent);
+
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ mRotaryService.onRotaryEvents(validDisplayId, events);
+
+ AccessibilityNodeInfo button3Node = createNode("button3");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button3Node);
+
+ // Rotating the controller clockwise again should do nothing because button3 is the last
+ // child of its ancestor FocusArea and the ancestor FocusArea doesn't support wrap-around.
+ mRotaryService.onRotaryEvents(validDisplayId, events);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(button3Node);
+
+ // Rotating the controller counterclockwise should move focus to defaultFocus.
+ clockwise = false;
+ rotaryEvent = new RotaryEvent(inputType, clockwise, timestamps);
+ events = Collections.singletonList(rotaryEvent);
+ mRotaryService.onRotaryEvents(validDisplayId, events);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(defaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#nudgeTo(List, int)} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * HUN FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testNudgeTo_nudgeToHun() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo hunRoot = createNode("hun_root");
+ AccessibilityWindowInfo hunWindow = new WindowBuilder()
+ .setRoot(hunRoot)
+ .build();
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(hunWindow);
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ AccessibilityNodeInfo hunButton1 = createNode("hun_button1");
+ AccessibilityNodeInfo mockHunFpv = mock(AccessibilityNodeInfo.class);
+ doAnswer(invocation -> {
+ mRotaryService.setFocusedNode(hunButton1);
+ return true;
+ }).when(mockHunFpv).performAction(ACTION_RESTORE_DEFAULT_FOCUS);
+ when(mockHunFpv.refresh()).thenReturn(true);
+ when(mockHunFpv.getClassName()).thenReturn(Utils.FOCUS_PARKING_VIEW_CLASS_NAME);
+ when(mNavigator.findFocusParkingViewInRoot(hunRoot)).thenReturn(mockHunFpv);
+ when(mNavigator.findHunWindow(anyList())).thenReturn(hunWindow);
+
+ assertThat(mRotaryService.getFocusedNode()).isNotEqualTo(hunButton1);
+
+ int hunNudgeDirection = mRotaryService.mHunNudgeDirection;
+ mRotaryService.nudgeTo(windows, hunNudgeDirection);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(hunButton1);
+ }
+
+ /**
+ * Tests {@link RotaryService#nudgeTo(List, int)} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * HUN FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testNudgeTo_nudgeToNudgeShortcut() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton1 = activity.findViewById(R.id.app_button1);
+ appButton1.post(() -> appButton1.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton1.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton1Node = createNode("app_button1");
+ mRotaryService.setFocusedNode(appButton1Node);
+
+ mRotaryService.nudgeTo(windows, View.FOCUS_RIGHT);
+ AccessibilityNodeInfo nudgeShortcutNode = createNode("nudge_shortcut");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(nudgeShortcutNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#nudgeTo(List, int)} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * HUN FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testNudgeTo_nudgeToUserSpecifiedTarget() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton2 = activity.findViewById(R.id.app_button2);
+ appButton2.post(() -> appButton2.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton2.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton2Node = createNode("app_button2");
+ mRotaryService.setFocusedNode(appButton2Node);
+
+ mRotaryService.nudgeTo(windows, View.FOCUS_LEFT);
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#nudgeTo(List, int)} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * HUN FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testNudgeTo_nudgeToNearestTarget() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ AccessibilityNodeInfo appFocusArea3Node = createNode("app_focus_area3");
+ mRotaryService.setFocusedNode(appButton3Node);
+
+ AccessibilityNodeInfo appFocusArea1Node = createNode("app_focus_area1");
+ when(mNavigator.findNudgeTargetFocusArea(
+ windows, appButton3Node, appFocusArea3Node, View.FOCUS_UP))
+ .thenReturn(AccessibilityNodeInfo.obtain(appFocusArea1Node));
+
+ mRotaryService.nudgeTo(windows, View.FOCUS_UP);
+ AccessibilityNodeInfo appButton1Node = createNode("app_button1");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appButton1Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . (target) . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (source) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_nudgeUp_moveFocus() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ AccessibilityNodeInfo appFocusArea3Node = createNode("app_focus_area3");
+ mRotaryService.setFocusedNode(appButton3Node);
+
+ AccessibilityNodeInfo appFocusArea1Node = createNode("app_focus_area1");
+ when(mNavigator.findNudgeTargetFocusArea(
+ windows, appButton3Node, appFocusArea3Node, View.FOCUS_UP))
+ .thenReturn(AccessibilityNodeInfo.obtain(appFocusArea1Node));
+
+ // Nudge up the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent nudgeUpEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(nudgeUpEventActionDown));
+ KeyEvent nudgeUpEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeUpEventActionUp));
+
+ // It should move focus to the FocusArea above.
+ AccessibilityNodeInfo appButton1Node = createNode("app_button1");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appButton1Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_nudgeUp_initFocus() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // RotaryService.mFocusedNode is not initialized.
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ // Move focus to the FocusParkingView.
+ Activity activity = mActivityRule.getActivity();
+ FocusParkingView fpv = activity.findViewById(R.id.app_fpv);
+ fpv.setShouldRestoreFocus(false);
+ fpv.post(() -> fpv.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(fpv.isFocused()).isTrue();
+ assertNull(mRotaryService.getFocusedNode());
+
+ // Nudge up the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent nudgeUpEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(nudgeUpEventActionDown));
+ KeyEvent nudgeUpEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeUpEventActionUp));
+
+ // It should initialize the focus.
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_nudgeToHunEscapeNudgeDirection_leaveTheHun() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ AccessibilityNodeInfo hunRoot = createNode("hun_root");
+ AccessibilityWindowInfo hunWindow = new WindowBuilder()
+ .setRoot(hunRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ windows.add(hunWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ // A Button in the HUN window is focused.
+ Activity activity = mActivityRule.getActivity();
+ Button hunButton1 = activity.findViewById(R.id.hun_button1);
+ hunButton1.post(() -> hunButton1.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(hunButton1.isFocused()).isTrue();
+ AccessibilityNodeInfo hunButton1Node = createNode("hun_button1");
+ AccessibilityNodeInfo hunFocusAreaNode = createNode("hun_focus_area");
+ mRotaryService.setFocusedNode(hunButton1Node);
+
+ // Set HUN escape nudge direction to View.FOCUS_DOWN.
+ mRotaryService.mHunEscapeNudgeDirection = View.FOCUS_DOWN;
+
+ AccessibilityNodeInfo appFocusArea3Node = createNode("app_focus_area3");
+ when(mNavigator.findNudgeTargetFocusArea(
+ windows, hunButton1Node, hunFocusAreaNode, View.FOCUS_DOWN))
+ .thenReturn(AccessibilityNodeInfo.obtain(appFocusArea3Node));
+
+ // Nudge down the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent nudgeEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeEventActionDown));
+ KeyEvent nudgeEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeEventActionUp));
+
+ // Nudging down should exit the HUN and focus in app_focus_area3.
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_nudgeToNonHunEscapeNudgeDirection_stayInTheHun() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ AccessibilityNodeInfo hunRoot = createNode("hun_root");
+ AccessibilityWindowInfo hunWindow = new WindowBuilder()
+ .setRoot(hunRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ windows.add(hunWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+
+ // A Button in the HUN window is focused.
+ Activity activity = mActivityRule.getActivity();
+ Button hunButton1 = activity.findViewById(R.id.hun_button1);
+ hunButton1.post(() -> hunButton1.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(hunButton1.isFocused()).isTrue();
+ AccessibilityNodeInfo hunButton1Node = createNode("hun_button1");
+ AccessibilityNodeInfo hunFocusAreaNode = createNode("hun_focus_area");
+ mRotaryService.setFocusedNode(hunButton1Node);
+
+ // Set HUN escape nudge direction to View.FOCUS_UP.
+ mRotaryService.mHunEscapeNudgeDirection = View.FOCUS_UP;
+
+ // RotaryService.mFocusedNode.getWindow() returns null in the test, so just pass null value
+ // to simplify the test.
+ when(mNavigator.isHunWindow(null)).thenReturn(true);
+
+ AccessibilityNodeInfo appFocusArea3Node = createNode("app_focus_area3");
+ when(mNavigator.findNudgeTargetFocusArea(
+ windows, hunButton1Node, hunFocusAreaNode, View.FOCUS_DOWN))
+ .thenReturn(AccessibilityNodeInfo.obtain(appFocusArea3Node));
+
+ // Nudge down the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent nudgeEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeEventActionDown));
+ KeyEvent nudgeEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN);
+ mRotaryService.onKeyEvents(validDisplayId, Collections.singletonList(nudgeEventActionUp));
+
+ // Nudging down should stay in the HUN because HUN escape nudge direction is View.FOCUS_UP.
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(hunButton1Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_centerButtonClick_initFocus() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // RotaryService.mFocusedNode is not initialized.
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+ assertThat(mRotaryService.getFocusedNode()).isNull();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+
+ // It should initialize the focus.
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (focused) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_centerButtonClickInAppWindow_injectDpadCenterEvent() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .setType(TYPE_APPLICATION)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ AccessibilityNodeInfo mockAppButton3Node = mNodeBuilder
+ .setFocused(true)
+ .setWindow(appWindow)
+ .build();
+ mRotaryService.setFocusedNode(mockAppButton3Node);
+
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+
+ // RotaryService should inject KEYCODE_DPAD_CENTER event because mockAppButton3Node is in
+ // the application window.
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_DOWN);
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_UP);
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isEqualTo(mockAppButton3Node);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(mockAppButton3Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (focused) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_centerButtonClickInSystemWindow_performActionClick() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ appButton3.setOnClickListener(v -> v.setActivated(true));
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ mRotaryService.setFocusedNode(appButton3Node);
+ mRotaryService.mLongPressMs = 400;
+
+ assertThat(appButton3.isActivated()).isFalse();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+
+ // appButton3Node.getWindow() will return null (because the test doesn't have the permission
+ // to create an AccessibilityWindowInfo), so appButton3Node isn't considered in the
+ // application window. Instead, it's considered in the system window. So RotaryService
+ // should perform ACTION_CLICK on it.
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isActivated()).isTrue();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isEqualTo(appButton3Node);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appButton3Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onKeyEvents} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (focused) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnKeyEvents_centerButtonLongClickInSystemWindow_performActionLongClick() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ AccessibilityNodeInfo appRoot = createNode("app_root");
+ AccessibilityWindowInfo appWindow = new WindowBuilder()
+ .setRoot(appRoot)
+ .build();
+ List<AccessibilityWindowInfo> windows = new ArrayList<>();
+ windows.add(appWindow);
+ when(mRotaryService.getWindows()).thenReturn(windows);
+ when(mRotaryService.getRootInActiveWindow())
+ .thenReturn(MockNodeCopierProvider.get().copy(mWindowRoot));
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ appButton3.setOnLongClickListener(v -> {
+ v.setActivated(true);
+ return true;
+ });
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ mRotaryService.setFocusedNode(appButton3Node);
+ mRotaryService.mLongPressMs = 0;
+
+ assertThat(appButton3.isActivated()).isFalse();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // appButton3Node.getWindow() will return null (because the test doesn't have the permission
+ // to create an AccessibilityWindowInfo), so appButton3Node isn't considered in the
+ // application window. Instead, it's considered in the system window. So RotaryService
+ // should perform ACTION_LONG_CLICK on it.
+ assertThat(appButton3.isActivated()).isTrue();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appButton3Node);
+ }
+
+ /**
+ * Tests {@link RotaryService#onAccessibilityEvent} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnAccessibilityEvent_typeViewFocused() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // The app focuses appDefaultFocus, then the accessibility framework sends a
+ // TYPE_VIEW_FOCUSED event.
+ // RotaryService should set mFocusedNode to appDefaultFocusNode.
+
+ Activity activity = mActivityRule.getActivity();
+ Button appDefaultFocus = activity.findViewById(R.id.app_default_focus);
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(appDefaultFocus.isFocused()).isTrue();
+ assertThat(mRotaryService.getFocusedNode()).isNull();
+
+ mRotaryService.mInRotaryMode = true;
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(appDefaultFocusNode));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_FOCUSED);
+ mRotaryService.onAccessibilityEvent(event);
+
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ }
+
+ /**
+ * Tests {@link RotaryService#onAccessibilityEvent} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnAccessibilityEvent_typeViewFocused2() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // RotaryService focuses appDefaultFocus, then the app focuses on the FocusParkingView
+ // and the accessibility framework sends a TYPE_VIEW_FOCUSED event.
+ // RotaryService should set mFocusedNode to null.
+
+ Activity activity = mActivityRule.getActivity();
+ Button appDefaultFocus = activity.findViewById(R.id.app_default_focus);
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(appDefaultFocus.isFocused()).isTrue();
+ mRotaryService.setFocusedNode(appDefaultFocusNode);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+
+ mRotaryService.mInRotaryMode = true;
+
+ AccessibilityNodeInfo fpvNode = createNode("app_fpv");
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(fpvNode));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_FOCUSED);
+ mRotaryService.onAccessibilityEvent(event);
+
+ assertThat(mRotaryService.getFocusedNode()).isNull();
+ }
+
+ /**
+ * Tests {@link RotaryService#onAccessibilityEvent} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnAccessibilityEvent_typeViewClicked() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // The focus is on appDefaultFocus, then the user clicks it via the rotary controller.
+
+ Activity activity = mActivityRule.getActivity();
+ Button appDefaultFocus = activity.findViewById(R.id.app_default_focus);
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(appDefaultFocus.isFocused()).isTrue();
+ mRotaryService.setFocusedNode(appDefaultFocusNode);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+
+ mRotaryService.mInRotaryMode = true;
+ mRotaryService.mIgnoreViewClickedNode = AccessibilityNodeInfo.obtain(appDefaultFocusNode);
+
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(appDefaultFocusNode));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_CLICKED);
+ when(event.getEventTime()).thenReturn(-1l);
+ mRotaryService.onAccessibilityEvent(event);
+
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+ assertThat(mRotaryService.mLastTouchedNode).isNull();
+ }
+
+ /**
+ * Tests {@link RotaryService#onAccessibilityEvent} in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . (focused) . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testOnAccessibilityEvent_typeViewClicked2() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ // The focus is on appDefaultFocus, then the user clicks appButton3 via the touch screen.
+
+ Activity activity = mActivityRule.getActivity();
+ Button appDefaultFocus = activity.findViewById(R.id.app_default_focus);
+ AccessibilityNodeInfo appDefaultFocusNode = createNode("app_default_focus");
+ assertThat(appDefaultFocus.isFocused()).isTrue();
+ mRotaryService.setFocusedNode(appDefaultFocusNode);
+ assertThat(mRotaryService.getFocusedNode()).isEqualTo(appDefaultFocusNode);
+
+ mRotaryService.mInRotaryMode = true;
+
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(appButton3Node));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_CLICKED);
+ when(event.getEventTime()).thenReturn(-1l);
+ mRotaryService.onAccessibilityEvent(event);
+
+ assertThat(mRotaryService.getFocusedNode()).isNull();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+ assertThat(mRotaryService.mLastTouchedNode).isEqualTo(appButton3Node);
+ }
+
+ @Test
+ public void testOnAccessibilityEvent_typeWindowStateChanged() {
+ AccessibilityWindowInfo window = mock(AccessibilityWindowInfo.class);
+ when(window.getType()).thenReturn(TYPE_APPLICATION);
+ when(window.getDisplayId()).thenReturn(DEFAULT_DISPLAY);
+
+ AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class);
+ when(node.getWindow()).thenReturn(window);
+
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(node);
+ when(event.getEventType()).thenReturn(TYPE_WINDOW_STATE_CHANGED);
+ final String packageName = "package.name";
+ final String className = "class.name";
+ when(event.getPackageName()).thenReturn(packageName);
+ when(event.getClassName()).thenReturn(className);
+ mRotaryService.onAccessibilityEvent(event);
+
+ ComponentName foregroundActivity = new ComponentName(packageName, className);
+ assertThat(mRotaryService.mForegroundActivity).isEqualTo(foregroundActivity);
+ }
+
+ /**
+ * Tests Direct Manipulation mode in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (focused) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testDirectManipulationMode1() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ DirectManipulationHelper.setSupportsRotateDirectly(appButton3, true);
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ mRotaryService.setFocusedNode(appButton3Node);
+ mRotaryService.mInRotaryMode = true;
+ assertThat(mRotaryService.mInDirectManipulationMode).isFalse();
+ assertThat(appButton3.isSelected()).isFalse();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+
+ // RotaryService should enter Direct Manipulation mode because appButton3Node
+ // supports rotate directly.
+ assertThat(mRotaryService.mInDirectManipulationMode).isTrue();
+ assertThat(appButton3.isSelected()).isTrue();
+
+ // Click the back button of the controller.
+ KeyEvent backButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(backButtonEventActionDown));
+ KeyEvent backButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(backButtonEventActionUp));
+
+ // RotaryService should exit Direct Manipulation mode because appButton3Node
+ // supports rotate directly.
+ assertThat(mRotaryService.mInDirectManipulationMode).isFalse();
+ assertThat(appButton3.isSelected()).isFalse();
+ }
+
+ /**
+ * Tests Direct Manipulation mode in the following view tree:
+ * <pre>
+ * The HUN window:
+ *
+ * hun FocusParkingView
+ * ==========HUN focus area==========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .hun button1. .hun button2. =
+ * = . . . . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ *
+ * The app window:
+ *
+ * app FocusParkingView
+ * ===========focus area 1=========== ============focus area 2===========
+ * = = = =
+ * = ............. ............. = = ............. =
+ * = . . . . = = . . =
+ * = .app button1. . nudge . = = .app button2. =
+ * = . . . shortcut . = = . . =
+ * = ............. ............. = = ............. =
+ * = = = =
+ * ================================== ===================================
+ *
+ * ===========focus area 3===========
+ * = =
+ * = ............. ............. =
+ * = . . . . =
+ * = .app button3. . default . =
+ * = . (focused) . . focus . =
+ * = ............. ............. =
+ * = =
+ * ==================================
+ * </pre>
+ */
+ @Test
+ public void testDirectManipulationMode2() {
+ initActivity(R.layout.rotary_service_test_2_activity);
+
+ Activity activity = mActivityRule.getActivity();
+ Button appButton3 = activity.findViewById(R.id.app_button3);
+ appButton3.post(() -> appButton3.requestFocus());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ assertThat(appButton3.isFocused()).isTrue();
+ AccessibilityNodeInfo appButton3Node = createNode("app_button3");
+ mRotaryService.setFocusedNode(appButton3Node);
+ mRotaryService.mInRotaryMode = true;
+ when(mRotaryService.isInApplicationWindow(appButton3Node)).thenReturn(true);
+ assertThat(mRotaryService.mInDirectManipulationMode).isFalse();
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isNull();
+
+ // Click the center button of the controller.
+ int validDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+ KeyEvent centerButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionDown));
+ KeyEvent centerButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(centerButtonEventActionUp));
+
+ // RotaryService should inject KEYCODE_DPAD_CENTER event because appButton3Node doesn't
+ // support rotate directly and is in the application window.
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_DOWN);
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_UP);
+ assertThat(mRotaryService.mIgnoreViewClickedNode).isEqualTo(appButton3Node);
+
+ // The app sends a TYPE_VIEW_ACCESSIBILITY_FOCUSED event to RotaryService.
+ // RotaryService should enter Direct Manipulation mode when receiving the event.
+ AccessibilityEvent event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(appButton3Node));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_ACCESSIBILITY_FOCUSED);
+ when(event.getClassName()).thenReturn(DIRECT_MANIPULATION);
+ mRotaryService.onAccessibilityEvent(event);
+ assertThat(mRotaryService.mInDirectManipulationMode).isTrue();
+
+ // Click the back button of the controller.
+ KeyEvent backButtonEventActionDown =
+ new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(backButtonEventActionDown));
+ KeyEvent backButtonEventActionUp =
+ new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK);
+ mRotaryService.onKeyEvents(validDisplayId,
+ Collections.singletonList(backButtonEventActionUp));
+
+ // RotaryService should inject KEYCODE_BACK event because appButton3Node doesn't
+ // support rotate directly and is in the application window.
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_BACK, KeyEvent.ACTION_DOWN);
+ verify(mRotaryService, times(1))
+ .injectKeyEvent(KeyEvent.KEYCODE_BACK, KeyEvent.ACTION_UP);
+
+ // The app sends a TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED event to RotaryService.
+ // RotaryService should exit Direct Manipulation mode when receiving the event.
+ event = mock(AccessibilityEvent.class);
+ when(event.getSource()).thenReturn(AccessibilityNodeInfo.obtain(appButton3Node));
+ when(event.getEventType()).thenReturn(TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
+ when(event.getClassName()).thenReturn(DIRECT_MANIPULATION);
+ mRotaryService.onAccessibilityEvent(event);
+ assertThat(mRotaryService.mInDirectManipulationMode).isFalse();
+ }
+
+ /**
+ * Starts the test activity with the given layout and initializes the root
+ * {@link AccessibilityNodeInfo}.
+ */
+ private void initActivity(@LayoutRes int layoutResId) {
+ mIntent.putExtra(NavigatorTestActivity.KEY_LAYOUT_ID, layoutResId);
+ mActivityRule.launchActivity(mIntent);
+ mWindowRoot = sUiAutomoation.getRootInActiveWindow();
+ }
+
+ /**
+ * Returns the {@link AccessibilityNodeInfo} related to the provided {@code viewId}. Returns
+ * null if no such node exists. Callers should ensure {@link #initActivity} has already been
+ * called. Caller shouldn't recycle the result because it will be recycled in {@link #tearDown}.
+ */
+ private AccessibilityNodeInfo createNode(String viewId) {
+ String fullViewId = "com.android.car.rotary.tests.unit:id/" + viewId;
+ List<AccessibilityNodeInfo> nodes =
+ mWindowRoot.findAccessibilityNodeInfosByViewId(fullViewId);
+ if (nodes.isEmpty()) {
+ L.e("Failed to create node by View ID " + viewId);
+ return null;
+ }
+ mNodes.addAll(nodes);
+ return nodes.get(0);
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/SurfaceViewHelperTest.java b/tests/unit/src/com/android/car/rotary/SurfaceViewHelperTest.java
new file mode 100644
index 0000000..49930a6
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/SurfaceViewHelperTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2021 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.android.car.rotary;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+
+@RunWith(AndroidJUnit4.class)
+public class SurfaceViewHelperTest {
+
+ private final static String HOST_APP_PACKAGE_NAME = "host.app.package.name";
+ private final static String CLIENT_APP_PACKAGE_NAME = "client.app.package.name";
+
+ private SurfaceViewHelper mSurfaceViewHelper;
+ private NodeBuilder mNodeBuilder;
+
+ @Before
+ public void setUp() {
+ mSurfaceViewHelper = new SurfaceViewHelper();
+ mNodeBuilder = new NodeBuilder(new ArrayList<>());
+ }
+
+ @Test
+ public void testIsHostNode() {
+ mSurfaceViewHelper.mHostApp = HOST_APP_PACKAGE_NAME;
+
+ AccessibilityNodeInfo node = mNodeBuilder.build();
+ assertThat(mSurfaceViewHelper.isHostNode(node)).isFalse();
+
+ AccessibilityNodeInfo hostNode = mNodeBuilder.setPackageName(HOST_APP_PACKAGE_NAME).build();
+ assertThat(mSurfaceViewHelper.isHostNode(hostNode)).isTrue();
+ }
+
+ @Test
+ public void testIsClientNode() {
+ mSurfaceViewHelper.addClientApp(CLIENT_APP_PACKAGE_NAME);
+
+ AccessibilityNodeInfo node = mNodeBuilder.build();
+ assertThat(mSurfaceViewHelper.isClientNode(node)).isFalse();
+
+ AccessibilityNodeInfo clientNode = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ assertThat(mSurfaceViewHelper.isClientNode(clientNode)).isTrue();
+ }
+
+ @Test
+ public void testAddClientApp() {
+ AccessibilityNodeInfo clientNode = mNodeBuilder
+ .setPackageName(CLIENT_APP_PACKAGE_NAME)
+ .build();
+ assertThat(mSurfaceViewHelper.isClientNode(clientNode)).isFalse();
+
+ mSurfaceViewHelper.addClientApp(CLIENT_APP_PACKAGE_NAME);
+ assertThat(mSurfaceViewHelper.isClientNode(clientNode)).isTrue();
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/TreeTraverserTest.java b/tests/unit/src/com/android/car/rotary/TreeTraverserTest.java
new file mode 100644
index 0000000..9ee3599
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/TreeTraverserTest.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.UiAutomation;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class TreeTraverserTest {
+
+ private static UiAutomation sUiAutomoation;
+
+ @Rule
+ public ActivityTestRule<TreeTraverserTestActivity> mActivityRule =
+ new ActivityTestRule<>(TreeTraverserTestActivity.class);
+
+ private TreeTraverser mTreeTraverser;
+
+ private AccessibilityNodeInfo mNode0;
+ private AccessibilityNodeInfo mNode1;
+ private AccessibilityNodeInfo mNode2;
+ private AccessibilityNodeInfo mNode3;
+ private AccessibilityNodeInfo mNode4;
+ private AccessibilityNodeInfo mNode5;
+ private AccessibilityNodeInfo mNode6;
+
+ @BeforeClass
+ public static void oneTimeSetup() {
+ sUiAutomoation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+ }
+
+ @Before
+ public void setUp() {
+ mTreeTraverser = new TreeTraverser();
+
+ AccessibilityNodeInfo root = sUiAutomoation.getRootInActiveWindow();
+
+ mNode0 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node0").get(0);
+ mNode1 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node1").get(0);
+ mNode2 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node2").get(0);
+ mNode3 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node3").get(0);
+ mNode4 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node4").get(0);
+ mNode5 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node5").get(0);
+ mNode6 = root.findAccessibilityNodeInfosByViewId(
+ "com.android.car.rotary.tests.unit:id/node6").get(0);
+
+ root.recycle();
+ }
+
+ @After
+ public void tearDown() {
+ Utils.recycleNodes(mNode0, mNode1, mNode2, mNode3, mNode4, mNode5, mNode6);
+ }
+
+ /**
+ * Tests
+ * {@link TreeTraverser#findNodeOrAncestor} in the following node tree:
+ * <pre>
+ * node0
+ * / \
+ * / \
+ * node1 node4
+ * / \ / \
+ * / \ / \
+ * node2 node3 node5 node6
+ * </pre>
+ */
+ @Test
+ public void testFindNodeOrAncestor() {
+ // Should check the node itself.
+ AccessibilityNodeInfo result = mTreeTraverser.findNodeOrAncestor(mNode0,
+ /* stopPredicate= */ null, /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isEqualTo(mNode0);
+ Utils.recycleNode(result);
+
+ // Parent.
+ result = mTreeTraverser.findNodeOrAncestor(mNode1, /* stopPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isEqualTo(mNode0);
+ Utils.recycleNode(result);
+
+ // Grandparent.
+ result = mTreeTraverser.findNodeOrAncestor(mNode2, /* stopPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isEqualTo(mNode0);
+ Utils.recycleNode(result);
+
+ // No ancestor found.
+ result = mTreeTraverser.findNodeOrAncestor(mNode2, /* stopPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode6));
+ assertThat(result).isNull();
+ Utils.recycleNode(result);
+
+ // Stop before target.
+ result = mTreeTraverser.findNodeOrAncestor(mNode2, /* stopPredicate= */
+ node -> node.equals(mNode1),
+ /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isNull();
+ Utils.recycleNode(result);
+
+ // Stop at target.
+ result = mTreeTraverser.findNodeOrAncestor(mNode2, /* stopPredicate= */
+ node -> node.equals(mNode0),
+ /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isNull();
+ Utils.recycleNode(result);
+ }
+
+ /**
+ * Tests {@link TreeTraverser#depthFirstSearch} in the following node tree:
+ * <pre>
+ * node0
+ * / \
+ * / \
+ * node1 node4
+ * / \ / \
+ * / \ / \
+ * node2 node3 node5 node6
+ * </pre>
+ */
+ @Test
+ public void testDepthFirstSearch() {
+ // Iterate in depth-first order, finding nothing.
+ List<AccessibilityNodeInfo> targetPredicateCalledWithNodes = new ArrayList<>();
+ AccessibilityNodeInfo result = mTreeTraverser.depthFirstSearch(
+ mNode0,
+ /* skipPredicate= */ null,
+ node -> {
+ targetPredicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return false;
+ });
+ assertThat(result).isNull();
+ assertThat(targetPredicateCalledWithNodes).containsExactly(mNode0, mNode1, mNode2,
+ mNode3, mNode4, mNode5, mNode6);
+ Utils.recycleNode(result);
+
+ // Find root.
+ result = mTreeTraverser.depthFirstSearch(mNode0, /* skipPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode0));
+ assertThat(result).isEqualTo(mNode0);
+ Utils.recycleNode(result);
+
+ // Find child.
+ result = mTreeTraverser.depthFirstSearch(mNode0, /* skipPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode4));
+ assertThat(result).isEqualTo(mNode4);
+ Utils.recycleNode(result);
+
+ // Find grandchild.
+ result = mTreeTraverser.depthFirstSearch(mNode0, /* skipPredicate= */ null,
+ /* targetPredicate= */ node -> node.equals(mNode6));
+ assertThat(result).isEqualTo(mNode6);
+ Utils.recycleNode(result);
+
+ // Iterate in depth-first order, skipping a subtree containing the target
+ List<AccessibilityNodeInfo> skipPredicateCalledWithNodes = new ArrayList<>();
+ targetPredicateCalledWithNodes.clear();
+ result = mTreeTraverser.depthFirstSearch(mNode0,
+ node -> {
+ skipPredicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return node.equals(mNode1);
+ },
+ node -> {
+ targetPredicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return node.equals(mNode2);
+ });
+ assertThat(result).isNull();
+ assertThat(skipPredicateCalledWithNodes).containsExactly(mNode0, mNode1, mNode4, mNode5,
+ mNode6);
+ assertThat(targetPredicateCalledWithNodes).containsExactly(mNode0, mNode4, mNode5, mNode6);
+ Utils.recycleNode(result);
+
+ // Skip subtree whose root is the target.
+ result = mTreeTraverser.depthFirstSearch(mNode0,
+ /* skipPredicate= */ node -> node.equals(mNode1),
+ /* skipPredicate= */ node -> node.equals(mNode1));
+ assertThat(result).isNull();
+ Utils.recycleNode(result);
+ }
+
+ /**
+ * Tests {@link TreeTraverser#reverseDepthFirstSearch} in the following node tree:
+ * <pre>
+ * node0
+ * / \
+ * / \
+ * node1 node4
+ * / \ / \
+ * / \ / \
+ * node2 node3 node5 node6
+ * </pre>
+ */
+ @Test
+ public void testReverseDepthFirstSearch() {
+ // Iterate in reverse depth-first order, finding nothing.
+ List<AccessibilityNodeInfo> predicateCalledWithNodes = new ArrayList<>();
+ AccessibilityNodeInfo result = mTreeTraverser.reverseDepthFirstSearch(
+ mNode0,
+ node -> {
+ predicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return false;
+ });
+ assertThat(result).isNull();
+ assertThat(predicateCalledWithNodes).containsExactly(
+ mNode6, mNode5, mNode4, mNode3, mNode2, mNode1, mNode0);
+ Utils.recycleNode(result);
+
+ // Find root.
+ result = mTreeTraverser.reverseDepthFirstSearch(mNode0, node -> node.equals(mNode0));
+ assertThat(result).isEqualTo(mNode0);
+ Utils.recycleNode(result);
+
+ // Find child.
+ result = mTreeTraverser.reverseDepthFirstSearch(mNode0, node -> node.equals(mNode1));
+ assertThat(result).isEqualTo(mNode1);
+ Utils.recycleNode(result);
+
+ // Find grandchild.
+ result = mTreeTraverser.reverseDepthFirstSearch(mNode0, node -> node.equals(mNode2));
+ assertThat(result).isEqualTo(mNode2);
+ Utils.recycleNode(result);
+ }
+
+ /**
+ * Tests {@link TreeTraverser#depthFirstSelect} in the following node tree:
+ * <pre>
+ * node0
+ * / \
+ * / \
+ * node1 node4
+ * / \ / \
+ * / \ / \
+ * node2 node3 node5 node6
+ * </pre>
+ */
+ @Test
+ public void testDepthFirstSelect() {
+ // Iterate in depth-first order, selecting no nodes.
+ List<AccessibilityNodeInfo> predicateCalledWithNodes = new ArrayList<>();
+ List<AccessibilityNodeInfo> selectedNodes = new ArrayList<>();
+ mTreeTraverser.depthFirstSelect(mNode0, node -> {
+ predicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return false;
+ }, selectedNodes);
+ assertThat(predicateCalledWithNodes).containsExactly(
+ mNode0, mNode1, mNode2, mNode3, mNode4, mNode5, mNode6);
+ assertThat(selectedNodes).isEmpty();
+ Utils.recycleNodes(selectedNodes);
+
+ // Find any node. Selects root and skips descendants.
+ predicateCalledWithNodes.clear();
+ selectedNodes = new ArrayList<>();
+ mTreeTraverser.depthFirstSelect(mNode0, node -> {
+ predicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return true;
+ }, selectedNodes);
+ assertThat(predicateCalledWithNodes).containsExactly(mNode0);
+ assertThat(selectedNodes).containsExactly(mNode0);
+ Utils.recycleNodes(selectedNodes);
+
+ // Find children of root node. Skips grandchildren.
+ predicateCalledWithNodes.clear();
+ selectedNodes = new ArrayList<>();
+ mTreeTraverser.depthFirstSelect(mNode0, node -> {
+ predicateCalledWithNodes.add(new AccessibilityNodeInfo(node));
+ return node.equals(mNode1) || node.equals(mNode4);
+ }, selectedNodes);
+ assertThat(predicateCalledWithNodes).containsExactly(mNode0, mNode1, mNode4);
+ assertThat(selectedNodes).containsExactly(mNode1, mNode4);
+ Utils.recycleNodes(selectedNodes);
+
+ // Find grandchildren of root node.
+ selectedNodes = new ArrayList<>();
+ mTreeTraverser.depthFirstSelect(mNode0,
+ node -> node.equals(mNode2) || node.equals(mNode3) || node.equals(mNode5)
+ || node.equals(mNode6),
+ selectedNodes);
+ assertThat(selectedNodes).containsExactly(mNode2, mNode3, mNode5, mNode6);
+ Utils.recycleNodes(selectedNodes);
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/TreeTraverserTestActivity.java b/tests/unit/src/com/android/car/rotary/TreeTraverserTestActivity.java
new file mode 100644
index 0000000..0fd3952
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/TreeTraverserTestActivity.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+
+/** An activity used for testing {@link com.android.car.rotary.TreeTraverser}. */
+public class TreeTraverserTestActivity extends Activity {
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.tree_traverser_test_activity);
+ }
+}
diff --git a/tests/robotests/src/com/android/car/rotary/WindowBuilder.java b/tests/unit/src/com/android/car/rotary/WindowBuilder.java
index 757d68c..eb7777f 100644
--- a/tests/robotests/src/com/android/car/rotary/WindowBuilder.java
+++ b/tests/unit/src/com/android/car/rotary/WindowBuilder.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 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.
@@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import android.graphics.Rect;
+import android.view.Display;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
@@ -41,12 +42,19 @@ class WindowBuilder {
private Rect mBoundsInScreen;
/** The window type, if specified. */
private int mType;
+ /** The display ID, if specified. */
+ private int mDisplayId = Display.DEFAULT_DISPLAY;
AccessibilityWindowInfo build() {
AccessibilityWindowInfo window = mock(AccessibilityWindowInfo.class);
when(window.getId()).thenReturn(mId);
- when(window.getRoot()).thenReturn(mRoot);
-
+ when(window.getRoot())
+ .thenReturn(MockNodeCopierProvider.get().copy(mRoot))
+ .thenReturn(MockNodeCopierProvider.get().copy(mRoot))
+ .thenReturn(MockNodeCopierProvider.get().copy(mRoot))
+ .thenReturn(MockNodeCopierProvider.get().copy(mRoot))
+ .thenThrow(new RuntimeException(
+ "Exceeded the maximum calls. Please add more parameters"));
if (mBoundsInScreen != null) {
// Mock AccessibilityWindowInfo#getBoundsInScreen(Rect).
doAnswer(invocation -> {
@@ -55,9 +63,8 @@ class WindowBuilder {
return null;
}).when(window).getBoundsInScreen(any(Rect.class));
}
-
when(window.getType()).thenReturn(mType);
-
+ when(window.getDisplayId()).thenReturn(mDisplayId);
return window;
}
@@ -67,7 +74,7 @@ class WindowBuilder {
}
WindowBuilder setRoot(@Nullable AccessibilityNodeInfo root) {
- mRoot = root;
+ mRoot = MockNodeCopierProvider.get().copy(root);
return this;
}
@@ -80,4 +87,9 @@ class WindowBuilder {
mType = type;
return this;
}
+
+ WindowBuilder setDisplayId(int displayId) {
+ mDisplayId = displayId;
+ return this;
+ }
}
diff --git a/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java b/tests/unit/src/com/android/car/rotary/WindowBuilderTest.java
index 01c0768..b3f219f 100644
--- a/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java
+++ b/tests/unit/src/com/android/car/rotary/WindowBuilderTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2021 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.
@@ -23,15 +23,15 @@ import android.graphics.Rect;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
import java.util.ArrayList;
-@RunWith(RobolectricTestRunner.class)
+@RunWith(AndroidJUnit4.class)
public class WindowBuilderTest {
-
@Test
public void testSetId() {
AccessibilityWindowInfo window = new WindowBuilder().setId(0x42).build();
@@ -42,7 +42,7 @@ public class WindowBuilderTest {
public void testSetRoot() {
AccessibilityNodeInfo root = new NodeBuilder(new ArrayList<>()).build();
AccessibilityWindowInfo window = new WindowBuilder().setRoot(root).build();
- assertThat(window.getRoot()).isSameInstanceAs(root);
+ assertThat(window.getRoot()).isEqualTo(root);
}
@Test
@@ -60,4 +60,11 @@ public class WindowBuilderTest {
new WindowBuilder().setType(TYPE_SYSTEM).build();
assertThat(window.getType()).isEqualTo(TYPE_SYSTEM);
}
+
+ @Test
+ public void testSetDisplayId() {
+ AccessibilityWindowInfo window =
+ new WindowBuilder().setDisplayId(3).build();
+ assertThat(window.getDisplayId()).isEqualTo(3);
+ }
}
diff --git a/tests/robotests/src/com/android/car/rotary/WindowCacheTest.java b/tests/unit/src/com/android/car/rotary/WindowCacheTest.java
index d33e7ba..1067230 100644
--- a/tests/robotests/src/com/android/car/rotary/WindowCacheTest.java
+++ b/tests/unit/src/com/android/car/rotary/WindowCacheTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * Copyright (C) 2020 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.
@@ -20,12 +20,13 @@ import static android.view.accessibility.AccessibilityWindowInfo.TYPE_SYSTEM;
import static com.google.common.truth.Truth.assertThat;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-@RunWith(RobolectricTestRunner.class)
+@RunWith(AndroidJUnit4.class)
public class WindowCacheTest {
private static final int WINDOW_ID_1 = 11;
diff --git a/tests/unit/src/com/android/car/rotary/ui/FocusParkingView.java b/tests/unit/src/com/android/car/rotary/ui/FocusParkingView.java
new file mode 100644
index 0000000..41b129a
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/ui/FocusParkingView.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary.ui;
+
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.inputmethod.InputMethodManager;
+import androidx.annotation.Nullable;
+
+/**
+ * A light version of FocusParkingView in CarUI Lib. It's used by
+ * {@link com.android.car.rotary.RotaryService} to clear the focus highlight in the previous
+ * window when moving focus to another window.
+ * <p>
+ * Unlike FocusParkingView in CarUI Lib, this view shouldn't be placed as the first focusable view
+ * in the window. The general recommendation is to keep this as the last view in the layout.
+ */
+public class FocusParkingView extends View {
+ /**
+ * This value should not change, even if the actual package containing this class is different.
+ */
+ private static final String FOCUS_PARKING_VIEW_LITE_CLASS_NAME =
+ "com.android.car.rotary.FocusParkingView";
+ /** Action performed on this view to hide the IME. */
+ private static final int ACTION_HIDE_IME = 0x08000000;
+ public FocusParkingView(Context context) {
+ super(context);
+ init();
+ }
+ public FocusParkingView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+ public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+ public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
+ int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+ private void init() {
+ // This view is focusable, visible and enabled so it can take focus.
+ setFocusable(View.FOCUSABLE);
+ setVisibility(VISIBLE);
+ setEnabled(true);
+ // This view is not clickable so it won't affect the app's behavior when the user clicks on
+ // it by accident.
+ setClickable(false);
+ // This view is always transparent.
+ setAlpha(0f);
+ // Prevent Android from drawing the default focus highlight for this view when it's focused.
+ setDefaultFocusHighlightEnabled(false);
+ }
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ // This size of the view is always 1 x 1 pixel, no matter what value is set in the layout
+ // file (match_parent, wrap_content, 100dp, 0dp, etc). Small size is to ensure it has little
+ // impact on the layout, non-zero size is to ensure it can take focus.
+ setMeasuredDimension(1, 1);
+ }
+ @Override
+ public void onWindowFocusChanged(boolean hasWindowFocus) {
+ if (!hasWindowFocus) {
+ // We need to clear the focus highlight(by parking the focus on this view)
+ // once the current window goes to background. This can't be done by RotaryService
+ // because RotaryService sees the window as removed, thus can't perform any action
+ // (such as focus, clear focus) on the nodes in the window. So this view has to
+ // grab the focus proactively.
+ super.requestFocus(FOCUS_DOWN, null);
+ }
+ super.onWindowFocusChanged(hasWindowFocus);
+ }
+ @Override
+ public CharSequence getAccessibilityClassName() {
+ return FOCUS_PARKING_VIEW_LITE_CLASS_NAME;
+ }
+ @Override
+ public boolean performAccessibilityAction(int action, Bundle arguments) {
+ switch (action) {
+ case ACTION_HIDE_IME:
+ InputMethodManager inputMethodManager =
+ getContext().getSystemService(InputMethodManager.class);
+ return inputMethodManager.hideSoftInputFromWindow(getWindowToken(),
+ /* flags= */ 0);
+ case ACTION_FOCUS:
+ // Don't leave this to View to handle as it will exit touch mode.
+ if (!hasFocus()) {
+ return super.requestFocus(FOCUS_DOWN, null);
+ }
+ return false;
+ }
+ return super.performAccessibilityAction(action, arguments);
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewAdapter.java b/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewAdapter.java
new file mode 100644
index 0000000..a0b9bdf
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewAdapter.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary.ui;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.car.rotary.R;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Test adapter for recycler view. */
+public class TestRecyclerViewAdapter extends RecyclerView.Adapter<TestRecyclerViewViewHolder> {
+
+ private final Context mContext;
+ private final List<String> mItems;
+ private boolean mItemsFocusable;
+
+ public TestRecyclerViewAdapter(Context context, int numItems) {
+ mContext = context;
+ mItems = new ArrayList<>();
+ for (int i = 0; i < numItems; i++) {
+ mItems.add("Test Item " + (i + 1));
+ }
+ }
+
+ public void setItemsFocusable(boolean itemsFocusable) {
+ mItemsFocusable = itemsFocusable;
+ }
+
+ @Override
+ public TestRecyclerViewViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View rootView = LayoutInflater.from(mContext).inflate(
+ R.layout.test_recycler_view_view_holder, parent, false);
+ rootView.setFocusable(mItemsFocusable);
+ return new TestRecyclerViewViewHolder(rootView);
+ }
+
+ @Override
+ public void onBindViewHolder(TestRecyclerViewViewHolder holder, int position) {
+ holder.bind(mItems.get(position));
+ holder.itemView.setFocusable(mItemsFocusable);
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItems.size();
+ }
+}
diff --git a/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewViewHolder.java b/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewViewHolder.java
new file mode 100644
index 0000000..c962af0
--- /dev/null
+++ b/tests/unit/src/com/android/car/rotary/ui/TestRecyclerViewViewHolder.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2020 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.android.car.rotary.ui;
+
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.car.rotary.R;
+
+/** Test view holder for recycler view. */
+public class TestRecyclerViewViewHolder extends RecyclerView.ViewHolder {
+
+ private final TextView mTextView;
+
+ public TestRecyclerViewViewHolder(@NonNull View itemView) {
+ super(itemView);
+ mTextView = itemView.findViewById(R.id.item);
+ }
+
+ /** Sets the text. */
+ public void bind(String text) {
+ mTextView.setText(text);
+ }
+}