1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.car.rotary; 17 18 import static android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT; 19 import static android.view.accessibility.AccessibilityWindowInfo.UNDEFINED_WINDOW_ID; 20 21 import static com.android.car.rotary.Utils.FOCUS_AREA_CLASS_NAME; 22 import static com.android.car.rotary.Utils.FOCUS_PARKING_VIEW_CLASS_NAME; 23 import static com.android.car.rotary.Utils.GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME; 24 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET; 25 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET; 26 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET; 27 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET; 28 import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER; 29 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE; 30 31 import static org.mockito.Mockito.any; 32 import static org.mockito.Mockito.doAnswer; 33 import static org.mockito.Mockito.mock; 34 import static org.mockito.Mockito.when; 35 import static org.mockito.internal.util.MockUtil.isMock; 36 37 import android.graphics.Rect; 38 import android.os.Bundle; 39 import android.view.accessibility.AccessibilityNodeInfo; 40 import android.view.accessibility.AccessibilityWindowInfo; 41 42 import androidx.annotation.NonNull; 43 import androidx.annotation.Nullable; 44 import androidx.annotation.VisibleForTesting; 45 46 import java.util.ArrayList; 47 import java.util.Arrays; 48 import java.util.List; 49 50 /** 51 * A builder which builds a mock {@link AccessibilityNodeInfo}. Unlike real nodes, mock nodes don't 52 * need to be recycled. 53 */ 54 class NodeBuilder { 55 @VisibleForTesting 56 static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 100, 100); 57 /** 58 * A list of mock nodes created via NodeBuilder. This list is used for searching for a 59 * node's child nodes. 60 */ 61 @NonNull 62 private final List<AccessibilityNodeInfo> mNodeList; 63 /** The window to which this node belongs. */ 64 @Nullable 65 private AccessibilityWindowInfo mWindow; 66 /** The window ID to which this node belongs. */ 67 private int mWindowId = UNDEFINED_WINDOW_ID; 68 /** The parent of this node. */ 69 @Nullable 70 private AccessibilityNodeInfo mParent; 71 /** The package this node comes from. */ 72 @Nullable 73 private String mPackageName; 74 /** The class this node comes from. */ 75 @Nullable 76 private String mClassName; 77 /** The node bounds in parent coordinates. */ 78 @NonNull 79 private Rect mBoundsInParent = new Rect(DEFAULT_BOUNDS); 80 /** The node bounds in screen coordinates. */ 81 @NonNull 82 private Rect mBoundsInScreen = new Rect(DEFAULT_BOUNDS); 83 /** Whether this node is focusable. */ 84 private boolean mFocusable = true; 85 /** Whether this node is focused. */ 86 private boolean mFocused = false; 87 /** Whether this node is visible to the user. */ 88 private boolean mVisibleToUser = true; 89 /** Whether this node is enabled. */ 90 private boolean mEnabled = true; 91 /** Whether the view represented by this node is still in the view tree. */ 92 private boolean mInViewTree = true; 93 /** Whether this node is scrollable. */ 94 private boolean mScrollable = false; 95 /** The content description for this node. */ 96 @Nullable 97 private String mContentDescription; 98 /** The state description for this node. */ 99 @Nullable 100 private String mStateDescription; 101 /** The action list for this node. */ 102 @NonNull 103 private List<AccessibilityNodeInfo.AccessibilityAction> mActionList = new ArrayList<>(); 104 /** The extras of this node. */ 105 @NonNull 106 private Bundle mExtras = new Bundle(); 107 /** Whether this node is checkable. */ 108 private boolean mCheckable = false; 109 NodeBuilder(@onNull List<AccessibilityNodeInfo> nodeList)110 NodeBuilder(@NonNull List<AccessibilityNodeInfo> nodeList) { 111 mNodeList = nodeList; 112 } 113 build()114 AccessibilityNodeInfo build() { 115 // Make a copy of the current NodeBuilder. 116 NodeBuilder builder = cut(); 117 AccessibilityNodeInfo node = mock(AccessibilityNodeInfo.class); 118 when(node.getWindow()).thenReturn(builder.mWindow); 119 when(node.getWindowId()).thenReturn(builder.mWindowId); 120 if (builder.mParent == null || isMock(builder.mParent)) { 121 when(node.getParent()).thenReturn(MockNodeCopierProvider.get().copy(builder.mParent)); 122 } else { 123 when(node.getParent()).thenReturn( 124 // In Navigator, nodes will be recycled once they're no longer used. When a real 125 // node is recycled, it can't be used again, such as performing an action, 126 // otherwise it will cause the "Cannot perform this action on a not sealed 127 // instance" exception. If mParent is a real node and the mock getParent() 128 // always returns the same instance , it might cause the exception when 129 // getParent() is called multiple on the same mock node. 130 // To fix it, this method returns a different instance each time it's called. 131 // Note: if this method is called too many times and triggers the "Exceeded the 132 // maximum calls", please add more parameters. 133 MockNodeCopierProvider.get().copy(builder.mParent), 134 MockNodeCopierProvider.get().copy(builder.mParent), 135 MockNodeCopierProvider.get().copy(builder.mParent), 136 MockNodeCopierProvider.get().copy(builder.mParent), 137 MockNodeCopierProvider.get().copy(builder.mParent), 138 MockNodeCopierProvider.get().copy(builder.mParent)) 139 .thenThrow(new RuntimeException("Exceeded the maximum calls")); 140 } 141 // There is no need to mock getChildCount() or getChild() if mParent is null or a real node. 142 if (builder.mParent != null && isMock(builder.mParent)) { 143 // Mock AccessibilityNodeInfo#getChildCount(). 144 doAnswer(invocation -> { 145 int childCount = 0; 146 for (AccessibilityNodeInfo candidate : builder.mNodeList) { 147 if (builder.mParent.equals(candidate.getParent())) { 148 childCount++; 149 } 150 } 151 return childCount; 152 }).when(builder.mParent).getChildCount(); 153 // Mock AccessibilityNodeInfo#getChild(int). 154 doAnswer(invocation -> { 155 Object[] args = invocation.getArguments(); 156 int index = (int) args[0]; 157 for (AccessibilityNodeInfo candidate : builder.mNodeList) { 158 if (builder.mParent.equals(candidate.getParent())) { 159 if (index == 0) { 160 return candidate; 161 } else { 162 index--; 163 } 164 } 165 } 166 return null; 167 }).when(builder.mParent).getChild(any(Integer.class)); 168 } 169 when(node.getPackageName()).thenReturn(builder.mPackageName); 170 when(node.getClassName()).thenReturn(builder.mClassName); 171 doAnswer(invocation -> { 172 Object[] args = invocation.getArguments(); 173 ((Rect) args[0]).set(builder.mBoundsInParent); 174 return null; 175 }).when(node).getBoundsInParent(any(Rect.class)); 176 doAnswer(invocation -> { 177 Object[] args = invocation.getArguments(); 178 ((Rect) args[0]).set(builder.mBoundsInScreen); 179 return null; 180 }).when(node).getBoundsInScreen(any(Rect.class)); 181 when(node.getBoundsInScreen()).thenReturn(builder.mBoundsInScreen); 182 when(node.isFocusable()).thenReturn(builder.mFocusable); 183 when(node.isFocused()).thenReturn(builder.mFocused); 184 doAnswer(invocation -> { 185 if (node.isFocused()) { 186 return node; 187 } 188 for (int i = 0; i < node.getChildCount(); i++) { 189 AccessibilityNodeInfo child = node.getChild(i); 190 AccessibilityNodeInfo inputFocus = child.findFocus(FOCUS_INPUT); 191 if (inputFocus != null) { 192 return inputFocus; 193 } 194 } 195 return null; 196 }).when(node).findFocus(FOCUS_INPUT); 197 when(node.isVisibleToUser()).thenReturn(builder.mVisibleToUser); 198 when(node.isEnabled()).thenReturn(builder.mEnabled); 199 when(node.refresh()).thenReturn(builder.mInViewTree); 200 when(node.isScrollable()).thenReturn(builder.mScrollable); 201 when(node.getContentDescription()).thenReturn(builder.mContentDescription); 202 when(node.getStateDescription()).thenReturn(builder.mStateDescription); 203 when(node.getActionList()).thenReturn(builder.mActionList); 204 when(node.getExtras()).thenReturn(builder.mExtras); 205 when(node.isCheckable()).thenReturn(builder.mCheckable); 206 builder.mNodeList.add(node); 207 return node; 208 } 209 setWindow(@ullable AccessibilityWindowInfo window)210 NodeBuilder setWindow(@Nullable AccessibilityWindowInfo window) { 211 mWindow = window; 212 return this; 213 } 214 setWindowId(int windowId)215 NodeBuilder setWindowId(int windowId) { 216 mWindowId = windowId; 217 return this; 218 } 219 setParent(@ullable AccessibilityNodeInfo parent)220 NodeBuilder setParent(@Nullable AccessibilityNodeInfo parent) { 221 // The parent node could be a mock node or a real node. In the latter case, a copy is 222 // saved. 223 mParent = MockNodeCopierProvider.get().copy(parent); 224 return this; 225 } 226 setPackageName(@ullable String packageName)227 NodeBuilder setPackageName(@Nullable String packageName) { 228 mPackageName = packageName; 229 return this; 230 } 231 setClassName(@ullable String className)232 NodeBuilder setClassName(@Nullable String className) { 233 mClassName = className; 234 return this; 235 } 236 setBoundsInParent(@onNull Rect boundsInParent)237 NodeBuilder setBoundsInParent(@NonNull Rect boundsInParent) { 238 mBoundsInParent = boundsInParent; 239 return this; 240 } 241 setBoundsInScreen(@onNull Rect boundsInScreen)242 NodeBuilder setBoundsInScreen(@NonNull Rect boundsInScreen) { 243 mBoundsInScreen = boundsInScreen; 244 return this; 245 } 246 setFocusable(boolean focusable)247 NodeBuilder setFocusable(boolean focusable) { 248 mFocusable = focusable; 249 return this; 250 } 251 setFocused(boolean focused)252 NodeBuilder setFocused(boolean focused) { 253 mFocused = focused; 254 return this; 255 } 256 setVisibleToUser(boolean visibleToUser)257 NodeBuilder setVisibleToUser(boolean visibleToUser) { 258 mVisibleToUser = visibleToUser; 259 return this; 260 } 261 setEnabled(boolean enabled)262 NodeBuilder setEnabled(boolean enabled) { 263 mEnabled = enabled; 264 return this; 265 } 266 setInViewTree(boolean inViewTree)267 NodeBuilder setInViewTree(boolean inViewTree) { 268 mInViewTree = inViewTree; 269 return this; 270 } 271 setScrollable(boolean scrollable)272 NodeBuilder setScrollable(boolean scrollable) { 273 mScrollable = scrollable; 274 return this; 275 } 276 setContentDescription(@ullable String contentDescription)277 NodeBuilder setContentDescription(@Nullable String contentDescription) { 278 mContentDescription = contentDescription; 279 return this; 280 } 281 setStateDescription(@ullable String stateDescription)282 NodeBuilder setStateDescription(@Nullable String stateDescription) { 283 mStateDescription = stateDescription; 284 return this; 285 } 286 setActions(AccessibilityNodeInfo.AccessibilityAction... actions)287 NodeBuilder setActions(AccessibilityNodeInfo.AccessibilityAction... actions) { 288 mActionList = Arrays.asList(actions); 289 return this; 290 } 291 setFocusArea()292 NodeBuilder setFocusArea() { 293 return setClassName(FOCUS_AREA_CLASS_NAME).setFocusable(false); 294 } 295 setFocusAreaBoundsOffset(int left, int top, int right, int bottom)296 NodeBuilder setFocusAreaBoundsOffset(int left, int top, int right, int bottom) { 297 mExtras.putInt(FOCUS_AREA_LEFT_BOUND_OFFSET, left); 298 mExtras.putInt(FOCUS_AREA_TOP_BOUND_OFFSET, top); 299 mExtras.putInt(FOCUS_AREA_RIGHT_BOUND_OFFSET, right); 300 mExtras.putInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET, bottom); 301 return this; 302 } 303 setFpv()304 NodeBuilder setFpv() { 305 return setClassName(FOCUS_PARKING_VIEW_CLASS_NAME); 306 } 307 setGenericFpv()308 NodeBuilder setGenericFpv() { 309 return setClassName(GENERIC_FOCUS_PARKING_VIEW_CLASS_NAME); 310 } 311 setScrollableContainer()312 NodeBuilder setScrollableContainer() { 313 return setContentDescription(ROTARY_VERTICALLY_SCROLLABLE); 314 } 315 setRotaryContainer()316 NodeBuilder setRotaryContainer() { 317 return setContentDescription(ROTARY_CONTAINER); 318 } 319 setCheckable(boolean checkable)320 NodeBuilder setCheckable(boolean checkable) { 321 mCheckable = checkable; 322 return this; 323 } 324 325 /** 326 * Creates a copy of the current NodeBuilder, and clears the states of the current NodeBuilder 327 * except for {@link #mNodeList}. 328 */ cut()329 private NodeBuilder cut() { 330 // Create a copy. 331 NodeBuilder copy = new NodeBuilder(this.mNodeList); 332 copy.mWindow = mWindow; 333 copy.mWindowId = mWindowId; 334 copy.mParent = mParent; 335 copy.mPackageName = mPackageName; 336 copy.mClassName = mClassName; 337 copy.mBoundsInParent = mBoundsInParent; 338 copy.mBoundsInScreen = mBoundsInScreen; 339 copy.mFocusable = mFocusable; 340 copy.mFocused = mFocused; 341 copy.mVisibleToUser = mVisibleToUser; 342 copy.mEnabled = mEnabled; 343 copy.mInViewTree = mInViewTree; 344 copy.mScrollable = mScrollable; 345 copy.mContentDescription = mContentDescription; 346 copy.mStateDescription = mStateDescription; 347 copy.mActionList = mActionList; 348 copy.mExtras = mExtras; 349 copy.mCheckable = mCheckable; 350 // Clear the states so that it doesn't infect the next NodeBuilder we create. 351 mWindow = null; 352 mWindowId = UNDEFINED_WINDOW_ID; 353 mParent = null; 354 mPackageName = null; 355 mClassName = null; 356 mBoundsInParent = new Rect(DEFAULT_BOUNDS); 357 mBoundsInScreen = new Rect(DEFAULT_BOUNDS); 358 mFocusable = true; 359 mFocused = false; 360 mVisibleToUser = true; 361 mEnabled = true; 362 mInViewTree = true; 363 mScrollable = false; 364 mContentDescription = null; 365 mStateDescription = null; 366 mActionList = new ArrayList<>(); 367 mExtras = new Bundle(); 368 mCheckable = false; 369 return copy; 370 } 371 } 372