1 /* 2 * Copyright (C) 2016 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 17 package android.platform.test.helpers; 18 19 import android.app.Instrumentation; 20 import android.platform.test.helpers.exceptions.UiTimeoutException; 21 import android.platform.test.helpers.exceptions.UnknownUiException; 22 import android.platform.test.utils.DPadUtil; 23 import android.support.test.launcherhelper.ILeanbackLauncherStrategy; 24 import android.support.test.launcherhelper.LauncherStrategyFactory; 25 import android.support.test.uiautomator.By; 26 import android.support.test.uiautomator.BySelector; 27 import android.support.test.uiautomator.Direction; 28 import android.support.test.uiautomator.UiObject2; 29 import android.support.test.uiautomator.Until; 30 import android.util.Log; 31 32 /** 33 * This app helper handles the following important widgets for TV apps: 34 * BrowseFragment, DetailsFragment, SearchFragment and PlaybackOverlayFragment 35 */ 36 public abstract class AbstractLeanbackAppHelper extends AbstractStandardAppHelper { 37 38 private static final String TAG = AbstractLeanbackAppHelper.class.getSimpleName(); 39 private static final long OPEN_ROW_CONTENT_WAIT_TIME_MS = 5000; 40 private static final long OPEN_HEADER_WAIT_TIME_MS = 5000; 41 private static final int OPEN_SIDE_PANEL_MAX_ATTEMPTS = 5; 42 private static final long MAIN_ACTIVITY_WAIT_TIME_MS = 250; 43 private static final long SELECT_WAIT_TIME_MS = 5000; 44 45 // The notable widget classes in Leanback Library 46 public enum Widget { 47 BROWSE_HEADERS_FRAGMENT, 48 BROWSE_ROWS_FRAGMENT, 49 DETAILS_FRAGMENT, 50 SEARCH_FRAGMENT, 51 VERTICAL_GRID_FRAGMENT, 52 GUIDED_STEP_FRAGMENT, 53 PLAYBACK_OVERLAY_FRAGMENT, 54 ERROR_FRAGMENT 55 } 56 57 protected DPadUtil mDPadUtil; 58 public ILeanbackLauncherStrategy mLauncherStrategy; 59 60 61 public AbstractLeanbackAppHelper(Instrumentation instr) { 62 super(instr); 63 mDPadUtil = new DPadUtil(instr); 64 mLauncherStrategy = LauncherStrategyFactory.getInstance( 65 mDevice).getLeanbackLauncherStrategy(); 66 } 67 68 /** 69 * @return {@link BySelector} describing the row headers (in the left pane) in 70 * the Browse fragment 71 */ 72 protected BySelector getBrowseHeadersSelector() { 73 return By.res(getPackage(), "browse_headers").hasChild(By.selected(true)); 74 } 75 76 /** 77 * @return {@link BySelector} describing a row content (in the right pane) selected in 78 * the Browse fragment 79 */ 80 protected BySelector getBrowseRowsSelector() { 81 return By.res(getPackage(), "row_content").hasChild(By.selected(true)); 82 } 83 84 /** 85 * @return {@link BySelector} describing the Details fragment 86 */ 87 protected BySelector getDetailsFragmentSelector() { 88 return By.res(getPackage(), "details_fragment"); 89 } 90 91 /** 92 * @return {@link BySelector} describing the Search fragment 93 */ 94 protected BySelector getSearchFragmentSelector() { 95 return By.res(getPackage(), "lb_search_frame"); 96 } 97 98 /** 99 * @return {@link BySelector} describing the Vertical grid fragment 100 */ 101 protected BySelector getVerticalGridFragmentSelector() { 102 return By.res(getPackage(), "grid_frame"); 103 } 104 105 /** 106 * @return {@link BySelector} describing the Guided step fragment 107 */ 108 protected BySelector getGuidedStepFragmentSelector() { 109 return By.res(getPackage(), "guidedactions_list"); 110 } 111 112 /** 113 * @return {@link BySelector} describing the Playback overlay fragment 114 */ 115 protected BySelector getPlaybackOverlayFragmentSelector() { 116 return By.res(getPackage(), "playback_controls_dock"); 117 } 118 119 /** 120 * @return {@link BySelector} describing the Error fragment 121 */ 122 protected BySelector getErrorFragmentSelector() { 123 return By.res(getPackage(), "error_frame"); 124 } 125 126 /** 127 * @return {@link BySelector} describing the main activity (mostly the Browse fragment). 128 * Note that not every application has its main activity, so the override is optional. 129 */ 130 protected BySelector getMainActivitySelector() { 131 return null; 132 } 133 134 // TODO Move waitForOpen and open to AbstractStandardAppHelper 135 /** 136 * Setup expectation: None. Waits for the application to begin running. 137 * @param timeoutMs 138 * @return true if the application is open successfully 139 */ 140 public boolean waitForOpen(long timeoutMs) { 141 return mDevice.wait(Until.hasObject(By.pkg(getPackage()).depth(0)), timeoutMs); 142 } 143 144 /** 145 * Setup expectation: On the launcher home screen. 146 * <p> 147 * Launches the desired application and wait for it to begin running before returning. 148 * </p> 149 * @param timeoutMs 150 */ 151 public void open(long timeoutMs) { 152 open(); 153 if (!waitForOpen(timeoutMs)) { 154 throw new UiTimeoutException(String.format("Timed out to open a target package %s:" 155 + " %d(ms)", getPackage(), timeoutMs)); 156 } 157 } 158 159 /** 160 * Setup expectation: Side panel is selected on the Browse fragment 161 * <p> 162 * Best effort attempt to go to the row headers, and open the selected header. 163 * </p> 164 */ 165 public void openHeader(String headerName) { 166 openBrowseHeaders(); 167 // header is focused; it should not be after pressing the DPad 168 selectHeader(headerName); 169 mDevice.pressDPadCenter(); 170 171 // Test for focus change and selection result 172 BySelector rowContent = getBrowseRowsSelector(); 173 if (!mDevice.wait(Until.hasObject(rowContent), OPEN_ROW_CONTENT_WAIT_TIME_MS)) { 174 throw new UnknownUiException( 175 String.format("Failed to find row content that matches the header: %s", 176 headerName)); 177 } 178 Log.v(TAG, "Successfully opened header"); 179 } 180 181 /** 182 * Setup expectation: On navigation screen on the Browse fragment 183 * 184 * Best effort attempt to open the row headers in the Browse fragment. 185 * @param onMainActivity True if it opens the side panel on app's main activity. 186 */ 187 public void openBrowseHeaders(boolean onMainActivity) { 188 if (onMainActivity) { 189 returnToMainActivity(); 190 } 191 int attempts = 0; 192 while (!waitForBrowseHeadersSelected(OPEN_HEADER_WAIT_TIME_MS) 193 && attempts++ < OPEN_SIDE_PANEL_MAX_ATTEMPTS) { 194 mDevice.pressDPadLeft(); 195 } 196 if (attempts == OPEN_SIDE_PANEL_MAX_ATTEMPTS) { 197 throw new UnknownUiException("Failed to open side panel"); 198 } 199 } 200 201 public void openBrowseHeaders() { 202 openBrowseHeaders(false); 203 } 204 205 /** 206 * Select target item through the container in the given direction. 207 * @param container 208 * @param target 209 * @param direction 210 * @return the focused object 211 */ 212 public UiObject2 select(UiObject2 container, BySelector target, Direction direction) { 213 if (container == null) { 214 throw new IllegalArgumentException("The container should not be null."); 215 } 216 UiObject2 focus = container.findObject(By.focused(true)); 217 if (focus == null) { 218 throw new UnknownUiException("The container should have a focused descendant."); 219 } 220 while (!focus.hasObject(target)) { 221 UiObject2 prev = focus; 222 mDPadUtil.pressDPad(direction); 223 focus = container.findObject(By.focused(true)); 224 if (focus == null) { 225 mDPadUtil.pressDPad(Direction.reverse(direction)); 226 focus = container.findObject(By.focused(true)); 227 } 228 if (focus.equals(prev)) { 229 // It reached at the end, but no target is found. 230 return null; 231 } 232 } 233 return focus; 234 } 235 236 /** 237 * Setup expectation: On guided fragment. 238 * <p> 239 * Best effort attempt to select a given guided action. 240 * </p> 241 */ 242 public UiObject2 selectGuidedAction(String action) { 243 assertWidgetEquals(Widget.GUIDED_STEP_FRAGMENT); 244 UiObject2 container = mDevice.wait( 245 Until.findObject( 246 By.res(getPackage(), "guidedactions_list").hasChild(By.focused(true))), 247 SELECT_WAIT_TIME_MS); 248 // Search down, then up 249 BySelector selector = By.res(getPackage(), "guidedactions_item_title").text(action); 250 UiObject2 focused = select(container, selector, Direction.DOWN); 251 if (focused != null) { 252 return focused; 253 } 254 focused = select(container, selector, Direction.UP); 255 if (focused != null) { 256 return focused; 257 } 258 throw new UnknownUiException(String.format("Failed to select guided action: %s", action)); 259 } 260 261 /** 262 * Setup expectation: On guided fragment. Return the string in guidance title. 263 */ 264 public String getGuidanceTitleText() { 265 assertWidgetEquals(Widget.GUIDED_STEP_FRAGMENT); 266 UiObject2 object = mDevice.wait( 267 Until.findObject(By.res(getPackage(), "guidance_title")), SELECT_WAIT_TIME_MS); 268 return object.getText(); 269 } 270 271 /** 272 * Setup expectation: On row fragment. 273 * @param title of the card 274 * @return UIObject2 for the focusable card that matches a given name in title 275 */ 276 private UiObject2 getCardInRowByTitle(String title) { 277 assertWidgetEquals(Widget.BROWSE_ROWS_FRAGMENT); 278 return mDevice.wait(Until.findObject( 279 By.focused(true).hasDescendant(By.res(getPackage(), "title_text").text(title))), 280 SELECT_WAIT_TIME_MS); 281 } 282 283 /** 284 * Setup expectation: On row fragment. 285 * @param title of the card 286 * @return String text of content in a card that has a given name in title 287 */ 288 public String getCardContentText(String title) { 289 UiObject2 card = getCardInRowByTitle(title); 290 if (card == null) { 291 throw new IllegalStateException("Failed to find a card in row content " + title); 292 } 293 return card.findObject(By.res(getPackage(), "content_text")).getText(); 294 } 295 296 /** 297 * Setup expectation: On row fragment. 298 * @param title of the card 299 * @return true if it finds a card that matches a given name in title 300 */ 301 public boolean hasCardInRow(String title) { 302 return (getCardInRowByTitle(title) != null); 303 } 304 305 /** 306 * Setup expectation: On row fragment. 307 * <p> 308 * Open a card that matches a given title in row content 309 * </p> 310 * @param title of the card 311 */ 312 public void openCardInRow(String title) { 313 assertWidgetEquals(Widget.BROWSE_ROWS_FRAGMENT); 314 UiObject2 card = getCardInRowByTitle(title); 315 if (card == null) { 316 throw new IllegalStateException("Failed to find a card in row content " + title); 317 } 318 if (!card.isFocused()) { 319 card.click(); // move a focus 320 card = getCardInRowByTitle(title); 321 if (card == null) { 322 throw new IllegalStateException("Failed to find a card in row content " + title); 323 } 324 } 325 mDPadUtil.pressDPadCenter(); 326 mDevice.wait(Until.gone(By.res(getPackage(), "title_text").text(title)), 327 SELECT_WAIT_TIME_MS); 328 } 329 330 /** 331 * Attempts to return to main activity with getMainActivitySelector() 332 * by pressing the back button repeatedly and sleeping briefly to allow for UI slowness. 333 */ 334 public void returnToMainActivity() { 335 int maxBackAttempts = 10; 336 BySelector selector = getMainActivitySelector(); 337 if (selector == null) { 338 throw new IllegalStateException("getMainActivitySelector() should be overridden."); 339 } 340 while (!mDevice.wait(Until.hasObject(selector), MAIN_ACTIVITY_WAIT_TIME_MS) 341 && maxBackAttempts-- > 0) { 342 mDevice.pressBack(); 343 } 344 } 345 346 /** 347 * Setup expectation: None. 348 * <p> 349 * Asserts that a given widget provided by the Support Library is shown on TV app. 350 * </p> 351 */ 352 public void assertWidgetEquals(Widget expected) { 353 if (!hasWidget(expected)) { 354 throw new UnknownUiException("No widget matches " + expected.name()); 355 } 356 } 357 358 private boolean hasWidget(Widget expected) { 359 switch (expected) { 360 case BROWSE_HEADERS_FRAGMENT: 361 return mDevice.hasObject(getBrowseHeadersSelector()); 362 case BROWSE_ROWS_FRAGMENT: 363 return mDevice.hasObject(getBrowseRowsSelector()); 364 case DETAILS_FRAGMENT: 365 return mDevice.hasObject(getDetailsFragmentSelector()); 366 case SEARCH_FRAGMENT: 367 return mDevice.hasObject(getSearchFragmentSelector()); 368 case VERTICAL_GRID_FRAGMENT: 369 return mDevice.hasObject(getVerticalGridFragmentSelector()); 370 case GUIDED_STEP_FRAGMENT: 371 return mDevice.hasObject(getGuidedStepFragmentSelector()); 372 case PLAYBACK_OVERLAY_FRAGMENT: 373 return mDevice.hasObject(getPlaybackOverlayFragmentSelector()); 374 case ERROR_FRAGMENT: 375 return mDevice.hasObject(getErrorFragmentSelector()); 376 default: 377 Log.w(TAG, "Unable to find the widget in the list: " + expected.name()); 378 return false; 379 } 380 } 381 382 @Override 383 public void dismissInitialDialogs() { 384 return; 385 } 386 387 private boolean waitForBrowseHeadersSelected(long timeoutMs) { 388 return mDevice.wait(Until.hasObject(getBrowseHeadersSelector()), timeoutMs); 389 } 390 391 protected UiObject2 selectHeader(String headerName) { 392 UiObject2 container = mDevice.wait( 393 Until.findObject(getBrowseHeadersSelector()), OPEN_HEADER_WAIT_TIME_MS); 394 BySelector header = By.clazz(".TextView").text(headerName); 395 396 // Wait until the row header text appears at runtime. This needs to be long enough to run 397 // under low bandwidth environments in the test lab. 398 mDevice.wait(Until.findObject(header), 60 * 1000); 399 400 // Search up, then down 401 UiObject2 focused = select(container, header, Direction.UP); 402 if (focused != null) { 403 return focused; 404 } 405 focused = select(container, header, Direction.DOWN); 406 if (focused != null) { 407 return focused; 408 } 409 throw new UnknownUiException("Failed to select header"); 410 } 411 } 412