diff options
| author | Xin Li <delphij@google.com> | 2021-06-18 12:24:40 -0700 |
|---|---|---|
| committer | Xin Li <delphij@google.com> | 2021-06-18 12:24:40 -0700 |
| commit | 9ebbbcecd465420695bb7822cad2006a02572225 (patch) | |
| tree | 94f954ae9d123fbb823cec1187d7fa81791795ce | |
| parent | 53f14a3035ef8449eb9eda71e67c97edfa034611 (diff) | |
| parent | 56c499999b12691b9cd71081a78b76d894721f4a (diff) | |
| download | platform_packages_apps_Car_RotaryController-master.tar.gz platform_packages_apps_Car_RotaryController-master.tar.bz2 platform_packages_apps_Car_RotaryController-master.zip | |
DO NOT MERGE - Merge RQ3A.210605.005HEADandroid-s-beta-4android-s-beta-3masterandroid-s-beta-4
Bug: 190855093
Merged-In: Ibfb65007b2f1e9742e18b4453559bc3c4926b743
Change-Id: I3b84888b472d63a02e859a79e0ed160f34e87f36
58 files changed, 7065 insertions, 1425 deletions
@@ -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); + } +} |
