1 /* 2 * Copyright (C) 2017 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.support.test.launcherhelper; 18 19 import android.app.Instrumentation; 20 import android.content.pm.ApplicationInfo; 21 import android.content.pm.PackageManager; 22 import android.graphics.Point; 23 import android.os.RemoteException; 24 import android.os.SystemClock; 25 import android.platform.test.utils.DPadUtil; 26 import android.support.test.uiautomator.By; 27 import android.support.test.uiautomator.BySelector; 28 import android.support.test.uiautomator.Direction; 29 import android.support.test.uiautomator.UiDevice; 30 import android.support.test.uiautomator.UiObject2; 31 import android.support.test.uiautomator.Until; 32 import android.util.Log; 33 import android.view.KeyEvent; 34 35 import org.junit.Assert; 36 37 import java.io.ByteArrayOutputStream; 38 import java.io.IOException; 39 40 41 public class TvLauncherStrategy implements ILeanbackLauncherStrategy { 42 43 private static final String LOG_TAG = TvLauncherStrategy.class.getSimpleName(); 44 private static final String PACKAGE_LAUNCHER = "com.google.android.tvlauncher"; 45 46 private static final int APP_LAUNCH_TIMEOUT = 10000; 47 private static final int SHORT_WAIT_TIME = 5000; // 5 sec 48 private static final int UI_TRANSITION_WAIT_TIME = 1000; 49 50 // Note that the selector specifies criteria for matching an UI element from/to a focused item 51 private static final BySelector SELECTOR_TOP_ROW = By.res(PACKAGE_LAUNCHER, "top_row"); 52 private static final BySelector SELECTOR_APPS_ROW = By.res(PACKAGE_LAUNCHER, "apps_row"); 53 private static final BySelector SELECTOR_ALL_APPS_VIEW = 54 By.res(PACKAGE_LAUNCHER, "row_list_view"); 55 private static final BySelector SELECTOR_ALL_APPS_LOGO = 56 By.res(PACKAGE_LAUNCHER, "channel_logo").focused(true).descContains("Apps"); 57 private static final BySelector SELECTOR_CONFIG_CHANNELS_ROW = 58 By.res(PACKAGE_LAUNCHER, "configure_channels_row"); 59 60 protected UiDevice mDevice; 61 protected DPadUtil mDPadUtil; 62 private Instrumentation mInstrumentation; 63 64 /** 65 * A TvLauncherUnsupportedOperationException is an exception specific to TV Launcher. This will 66 * be thrown when the feature/method is not available on the TV Launcher. 67 */ 68 class TvLauncherUnsupportedOperationException extends UnsupportedOperationException { TvLauncherUnsupportedOperationException()69 TvLauncherUnsupportedOperationException() { 70 super(); 71 } TvLauncherUnsupportedOperationException(String msg)72 TvLauncherUnsupportedOperationException(String msg) { 73 super(msg); 74 } 75 } 76 77 /** 78 * {@inheritDoc} 79 */ 80 @Override getSupportedLauncherPackage()81 public String getSupportedLauncherPackage() { 82 return PACKAGE_LAUNCHER; 83 } 84 85 /** 86 * {@inheritDoc} 87 */ 88 // TODO(hyungtaekim): Move this common implementation to abstract class for TV launchers 89 @Override setUiDevice(UiDevice uiDevice)90 public void setUiDevice(UiDevice uiDevice) { 91 mDevice = uiDevice; 92 mDPadUtil = new DPadUtil(mDevice); 93 } 94 95 /** 96 * {@inheritDoc} 97 */ 98 @Override open()99 public void open() { 100 // if we see main list view, assume at home screen already 101 if (!mDevice.hasObject(getWorkspaceSelector())) { 102 mDPadUtil.pressHome(); 103 // ensure launcher is shown 104 if (!mDevice.wait(Until.hasObject(getWorkspaceSelector()), SHORT_WAIT_TIME)) { 105 // HACK: dump hierarchy to logcat 106 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 107 try { 108 mDevice.dumpWindowHierarchy(baos); 109 baos.flush(); 110 baos.close(); 111 String[] lines = baos.toString().split("\\r?\\n"); 112 for (String line : lines) { 113 Log.d(LOG_TAG, line.trim()); 114 } 115 } catch (IOException ioe) { 116 Log.e(LOG_TAG, "error dumping XML to logcat", ioe); 117 } 118 throw new RuntimeException("Failed to open TV launcher"); 119 } 120 mDevice.waitForIdle(); 121 } 122 } 123 124 /** 125 * {@inheritDoc} 126 * There are two different ways to open All Apps view. If longpress is true, it will long press 127 * the HOME key to open it. Otherwise it will navigate to the "APPS" logo on the Apps row. 128 */ 129 @Override openAllApps(boolean longpress)130 public UiObject2 openAllApps(boolean longpress) { 131 if (longpress) { 132 mDPadUtil.longPressKeyCode(KeyEvent.KEYCODE_HOME); 133 } else { 134 Assert.assertNotNull("Could not find all apps logo", selectAppsLogo()); 135 mDPadUtil.pressDPadCenter(); 136 } 137 return mDevice.wait(Until.findObject(getAllAppsSelector()), SHORT_WAIT_TIME); 138 } 139 140 /** 141 * {@inheritDoc} 142 */ 143 @Override getWorkspaceSelector()144 public BySelector getWorkspaceSelector() { 145 return By.res(getSupportedLauncherPackage(), "home_view_container"); 146 } 147 148 /** 149 * {@inheritDoc} 150 */ 151 @Override getSearchRowSelector()152 public BySelector getSearchRowSelector() { 153 return SELECTOR_TOP_ROW; 154 } 155 156 /** 157 * {@inheritDoc} 158 */ 159 @Override getAppsRowSelector()160 public BySelector getAppsRowSelector() { 161 return SELECTOR_APPS_ROW; 162 } 163 164 /** 165 * {@inheritDoc} 166 */ 167 @Override getGamesRowSelector()168 public BySelector getGamesRowSelector() { 169 // Note that the apps and games are now in the same row on new TV Launcher. 170 return getAppsRowSelector(); 171 } 172 173 /** 174 * {@inheritDoc} 175 */ 176 @Override getAllAppsScrollDirection()177 public Direction getAllAppsScrollDirection() { 178 return Direction.DOWN; 179 } 180 181 /** 182 * {@inheritDoc} 183 */ 184 @Override getAllAppsSelector()185 public BySelector getAllAppsSelector() { 186 return SELECTOR_ALL_APPS_VIEW; 187 } 188 getAllAppsLogoSelector()189 public BySelector getAllAppsLogoSelector() { 190 return SELECTOR_ALL_APPS_LOGO; 191 } 192 193 /** 194 * Returns a {@link BySelector} describing a given favorite app 195 */ getFavoriteAppSelector(String appName)196 public BySelector getFavoriteAppSelector(String appName) { 197 return By.res(getSupportedLauncherPackage(), "favorite_app_banner").text(appName); 198 } 199 200 /** 201 * Returns a {@link BySelector} describing a given app in Apps View 202 */ getAppInAppsViewSelector(String appName)203 public BySelector getAppInAppsViewSelector(String appName) { 204 return By.res(getSupportedLauncherPackage(), "app_title").text(appName); 205 } 206 207 /** 208 * {@inheritDoc} 209 */ 210 @Override launch(String appName, String packageName)211 public long launch(String appName, String packageName) { 212 return launchApp(this, appName, packageName, isGame(packageName)); 213 } 214 215 /** 216 * {@inheritDoc} 217 * <p> 218 * This function must be called before any UI test runs on TV. 219 * </p> 220 */ 221 @Override setInstrumentation(Instrumentation instrumentation)222 public void setInstrumentation(Instrumentation instrumentation) { 223 mInstrumentation = instrumentation; 224 } 225 226 /** 227 * {@inheritDoc} 228 */ 229 @Override selectSearchRow()230 public UiObject2 selectSearchRow() { 231 // The Search orb is now on top row on TV Launcher 232 return selectTopRow(); 233 } 234 235 /** 236 * {@inheritDoc} 237 */ 238 @Override selectAppsRow()239 public UiObject2 selectAppsRow() { 240 return selectBidirect(getAppsRowSelector().hasDescendant(By.focused(true)), 241 Direction.DOWN); 242 } 243 selectChannelsRow(String channelName)244 public UiObject2 selectChannelsRow(String channelName) { 245 // TODO: 246 return null; 247 } 248 selectAppsLogo()249 public UiObject2 selectAppsLogo() { 250 Assert.assertNotNull("Could not find all apps row", selectAppsRow()); 251 return selectBidirect(getAllAppsLogoSelector().hasDescendant(By.focused(true)), 252 Direction.LEFT); 253 } 254 255 /** 256 * Returns a {@link UiObject2} describing the Top row on TV Launcher 257 * @return 258 */ selectTopRow()259 public UiObject2 selectTopRow() { 260 return select(getSearchRowSelector().hasDescendant(By.focused(true)), 261 Direction.UP, UI_TRANSITION_WAIT_TIME); 262 } 263 264 /** 265 * Returns a {@link UiObject2} describing the Config Channels row on TV Launcher 266 * @return 267 */ selectConfigChannelsRow()268 public UiObject2 selectConfigChannelsRow() { 269 return select(SELECTOR_CONFIG_CHANNELS_ROW.hasDescendant(By.focused(true)), 270 Direction.DOWN, UI_TRANSITION_WAIT_TIME); 271 } 272 273 /** 274 * {@inheritDoc} 275 */ 276 @Override selectGamesRow()277 public UiObject2 selectGamesRow() { 278 return selectAppsRow(); 279 } 280 281 /** 282 * Select the given app in All Apps activity. 283 * When the All Apps opens, the focus is always at the top right. 284 * Search from left to right, and down to the next row, from right to left, and 285 * down to the next row like a zigzag pattern until the app is found. 286 */ selectAppInAllApps(BySelector appSelector, String packageName)287 protected UiObject2 selectAppInAllApps(BySelector appSelector, String packageName) { 288 openAllApps(true); 289 290 // Assume that the focus always starts at the top left of the Apps view. 291 final int maxScrollAttempts = 20; 292 final int margin = 10; 293 int attempts = 0; 294 UiObject2 focused = null; 295 UiObject2 expected = null; 296 while (attempts++ < maxScrollAttempts) { 297 focused = mDevice.wait(Until.findObject(By.focused(true)), SHORT_WAIT_TIME); 298 expected = mDevice.wait(Until.findObject(appSelector), SHORT_WAIT_TIME); 299 300 if (expected == null) { 301 mDPadUtil.pressDPadDown(); 302 continue; 303 } else if (focused.getVisibleCenter().equals(expected.getVisibleCenter())) { 304 // The app icon is on the screen, and selected. 305 Log.i(LOG_TAG, String.format("The app %s is selected", packageName)); 306 break; 307 } else { 308 // The app icon is on the screen, but not selected yet 309 // Move one step closer to the app icon 310 Point currentPosition = focused.getVisibleCenter(); 311 Point targetPosition = expected.getVisibleCenter(); 312 int dx = targetPosition.x - currentPosition.x; 313 int dy = targetPosition.y - currentPosition.y; 314 if (dy > margin) { 315 mDPadUtil.pressDPadDown(); 316 continue; 317 } 318 if (dx > margin) { 319 mDPadUtil.pressDPadRight(); 320 continue; 321 } 322 if (dy < -margin) { 323 mDPadUtil.pressDPadUp(); 324 continue; 325 } 326 if (dx < -margin) { 327 mDPadUtil.pressDPadLeft(); 328 continue; 329 } 330 throw new RuntimeException( 331 "Failed to navigate to the app icon on screen: " + packageName); 332 } 333 } 334 return expected; 335 } 336 337 /** 338 * Select the given app in All Apps activity in zigzag manner. 339 * When the All Apps opens, the focus is always at the top left. 340 * Search from left to right, and down to the next row, from right to left, and 341 * down to the next row like a zigzag pattern until it founds a given app. 342 */ selectAppInAllAppsZigZag(BySelector appSelector, String packageName)343 public UiObject2 selectAppInAllAppsZigZag(BySelector appSelector, String packageName) { 344 Direction direction = Direction.RIGHT; 345 UiObject2 app = select(appSelector, direction, UI_TRANSITION_WAIT_TIME); 346 while (app == null && move(Direction.DOWN)) { 347 direction = Direction.reverse(direction); 348 app = select(appSelector, direction, UI_TRANSITION_WAIT_TIME); 349 } 350 if (app != null) { 351 Log.i(LOG_TAG, String.format("The app %s is selected", packageName)); 352 } 353 return app; 354 } 355 launchApp(ILauncherStrategy launcherStrategy, String appName, String packageName, boolean isGame)356 protected long launchApp(ILauncherStrategy launcherStrategy, String appName, 357 String packageName, boolean isGame) { 358 unlockDeviceIfAsleep(); 359 360 if (isAppOpen(packageName)) { 361 // Application is already open 362 return 0; 363 } 364 365 // Go to the home page, and select the Apps row 366 launcherStrategy.open(); 367 selectAppsRow(); 368 369 // Search for the app in the Favorite Apps row first. 370 // If not exists, open the 'All Apps' and search for the app there 371 UiObject2 app = null; 372 BySelector favAppSelector = getFavoriteAppSelector(appName); 373 if (mDevice.hasObject(favAppSelector)) { 374 app = selectBidirect(By.focused(true).hasDescendant(favAppSelector), Direction.RIGHT); 375 } else { 376 openAllApps(true); 377 // Find app in Apps View in zigzag mode with app selector for Apps View 378 // because the app title no longer appears until focused. 379 app = selectAppInAllAppsZigZag(getAppInAppsViewSelector(appName), packageName); 380 } 381 if (app == null) { 382 throw new RuntimeException( 383 "Failed to navigate to the app icon on screen: " + packageName); 384 } 385 386 // The app icon is already found and focused. Then wait for it to open. 387 long ready = SystemClock.uptimeMillis(); 388 mDPadUtil.pressDPadCenter(); 389 if (packageName != null) { 390 if (!mDevice.wait(Until.hasObject(By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT)) { 391 Log.w(LOG_TAG, String.format( 392 "No UI element with package name %s detected.", packageName)); 393 return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP; 394 } 395 } 396 return ready; 397 } 398 isTopRowSelected()399 protected boolean isTopRowSelected() { 400 UiObject2 row = mDevice.findObject(getSearchRowSelector()); 401 if (row == null) { 402 return false; 403 } 404 return row.hasObject(By.focused(true)); 405 } 406 isAppsRowSelected()407 protected boolean isAppsRowSelected() { 408 UiObject2 row = mDevice.findObject(getAppsRowSelector()); 409 if (row == null) { 410 return false; 411 } 412 return row.hasObject(By.focused(true)); 413 } 414 isGamesRowSelected()415 protected boolean isGamesRowSelected() { 416 return isAppsRowSelected(); 417 } 418 419 // TODO(hyungtaekim): Move in the common helper isAppOpen(String appPackage)420 protected boolean isAppOpen(String appPackage) { 421 return mDevice.hasObject(By.pkg(appPackage).depth(0)); 422 } 423 424 // TODO(hyungtaekim): Move in the common helper unlockDeviceIfAsleep()425 protected void unlockDeviceIfAsleep() { 426 // Turn screen on if necessary 427 try { 428 if (!mDevice.isScreenOn()) { 429 mDevice.wakeUp(); 430 } 431 } catch (RemoteException e) { 432 Log.e(LOG_TAG, "Failed to unlock the screen-off device.", e); 433 } 434 } 435 isGame(String packageName)436 private boolean isGame(String packageName) { 437 boolean isGame = false; 438 if (mInstrumentation != null) { 439 try { 440 ApplicationInfo appInfo = 441 mInstrumentation.getTargetContext().getPackageManager().getApplicationInfo( 442 packageName, 0); 443 // TV game apps should use the "isGame" tag added since the L release. They are 444 // listed on the Games row on the TV Launcher. 445 isGame = (appInfo.metaData != null && appInfo.metaData.getBoolean("isGame", false)) 446 || ((appInfo.flags & ApplicationInfo.FLAG_IS_GAME) != 0); 447 Log.i(LOG_TAG, String.format("The package %s isGame: %b", packageName, isGame)); 448 } catch (PackageManager.NameNotFoundException e) { 449 Log.w(LOG_TAG, 450 String.format("No package found: %s, error:%s", packageName, e.toString())); 451 return false; 452 } 453 } 454 return isGame; 455 } 456 457 /** 458 * {@inheritDoc} 459 */ 460 @Override search(String query)461 public void search(String query) { 462 // TODO: Implement this method when the feature is available 463 throw new UnsupportedOperationException("search is not yet implemented"); 464 } 465 selectRestrictedProfile()466 public void selectRestrictedProfile() { 467 // TODO: Implement this method when the feature is available 468 throw new UnsupportedOperationException( 469 "The Restricted Profile is not yet available on TV Launcher."); 470 } 471 472 473 // Convenient methods for UI actions 474 475 /** 476 * Select an UI element with given {@link BySelector}. This action keeps moving a focus 477 * in a given {@link Direction} until it finds a matched element. 478 * @param selector the search criteria to match an element 479 * @param direction the direction to find 480 * @param timeoutMs timeout in milliseconds to select 481 * @return a UiObject2 which represents the matched element 482 */ select(BySelector selector, Direction direction, long timeoutMs)483 public UiObject2 select(BySelector selector, Direction direction, long timeoutMs) { 484 UiObject2 focus = mDevice.wait(Until.findObject(By.focused(true)), SHORT_WAIT_TIME); 485 while (!mDevice.wait(Until.hasObject(selector), timeoutMs)) { 486 Log.d(LOG_TAG, String.format("select: moving a focus from %s to %s", focus, direction)); 487 UiObject2 focused = focus; 488 mDPadUtil.pressDPad(direction); 489 focus = mDevice.wait(Until.findObject(By.focused(true)), SHORT_WAIT_TIME); 490 // Hack: A focus might be lost in some UI. Take one more step forward. 491 if (focus == null) { 492 mDPadUtil.pressDPad(direction); 493 focus = mDevice.wait(Until.findObject(By.focused(true)), SHORT_WAIT_TIME); 494 } 495 // Check if it reaches to an end where it no longer moves a focus to next element 496 if (focused.equals(focus)) { 497 Log.d(LOG_TAG, "select: not found until it reaches to an end."); 498 return null; 499 } 500 } 501 Log.i(LOG_TAG, String.format("select: %s is selected", focus)); 502 return focus; 503 } 504 505 /** 506 * Select an element with a given {@link BySelector} in both given direction and reverse. 507 */ selectBidirect(BySelector selector, Direction direction)508 public UiObject2 selectBidirect(BySelector selector, Direction direction) { 509 Log.d(LOG_TAG, String.format("selectBidirect [direction]%s", direction)); 510 UiObject2 object = select(selector, direction, UI_TRANSITION_WAIT_TIME); 511 if (object == null) { 512 object = select(selector, Direction.reverse(direction), UI_TRANSITION_WAIT_TIME); 513 } 514 return object; 515 } 516 517 /** 518 * Simulate a move pressing a key code. 519 * Return true if a focus is shifted on TV UI, otherwise false. 520 */ move(Direction direction)521 public boolean move(Direction direction) { 522 int keyCode = KeyEvent.KEYCODE_UNKNOWN; 523 switch (direction) { 524 case LEFT: 525 keyCode = KeyEvent.KEYCODE_DPAD_LEFT; 526 break; 527 case RIGHT: 528 keyCode = KeyEvent.KEYCODE_DPAD_RIGHT; 529 break; 530 case UP: 531 keyCode = KeyEvent.KEYCODE_DPAD_UP; 532 break; 533 case DOWN: 534 keyCode = KeyEvent.KEYCODE_DPAD_DOWN; 535 break; 536 default: 537 throw new RuntimeException(String.format("This direction %s is not supported.", 538 direction)); 539 } 540 UiObject2 focus = mDevice.wait(Until.findObject(By.focused(true)), 541 UI_TRANSITION_WAIT_TIME); 542 mDPadUtil.pressKeyCodeAndWait(keyCode); 543 return !focus.equals(mDevice.wait(Until.findObject(By.focused(true)), 544 UI_TRANSITION_WAIT_TIME)); 545 } 546 547 548 // Unsupported methods 549 550 @SuppressWarnings("unused") 551 @Override getNotificationRowSelector()552 public BySelector getNotificationRowSelector() { 553 throw new TvLauncherUnsupportedOperationException("No Notification row"); 554 } 555 556 @SuppressWarnings("unused") 557 @Override getSettingsRowSelector()558 public BySelector getSettingsRowSelector() { 559 throw new TvLauncherUnsupportedOperationException("No Settings row"); 560 } 561 562 @SuppressWarnings("unused") 563 @Override getAppWidgetSelector()564 public BySelector getAppWidgetSelector() { 565 throw new TvLauncherUnsupportedOperationException(); 566 } 567 568 @SuppressWarnings("unused") 569 @Override getNowPlayingCardSelector()570 public BySelector getNowPlayingCardSelector() { 571 throw new TvLauncherUnsupportedOperationException("No Now Playing Card"); 572 } 573 574 @SuppressWarnings("unused") 575 @Override selectNotificationRow()576 public UiObject2 selectNotificationRow() { 577 throw new TvLauncherUnsupportedOperationException("No Notification row"); 578 } 579 580 @SuppressWarnings("unused") 581 @Override selectSettingsRow()582 public UiObject2 selectSettingsRow() { 583 throw new TvLauncherUnsupportedOperationException("No Settings row"); 584 } 585 586 @SuppressWarnings("unused") 587 @Override hasAppWidgetSelector()588 public boolean hasAppWidgetSelector() { 589 throw new TvLauncherUnsupportedOperationException(); 590 } 591 592 @SuppressWarnings("unused") 593 @Override hasNowPlayingCard()594 public boolean hasNowPlayingCard() { 595 throw new TvLauncherUnsupportedOperationException("No Now Playing Card"); 596 } 597 598 @SuppressWarnings("unused") 599 @Override getAllAppsButtonSelector()600 public BySelector getAllAppsButtonSelector() { 601 throw new TvLauncherUnsupportedOperationException("No All Apps button"); 602 } 603 604 @SuppressWarnings("unused") 605 @Override openAllWidgets(boolean reset)606 public UiObject2 openAllWidgets(boolean reset) { 607 throw new TvLauncherUnsupportedOperationException("No All Widgets"); 608 } 609 610 @SuppressWarnings("unused") 611 @Override getAllWidgetsSelector()612 public BySelector getAllWidgetsSelector() { 613 throw new TvLauncherUnsupportedOperationException("No All Widgets"); 614 } 615 616 @SuppressWarnings("unused") 617 @Override getAllWidgetsScrollDirection()618 public Direction getAllWidgetsScrollDirection() { 619 throw new TvLauncherUnsupportedOperationException("No All Widgets"); 620 } 621 622 @SuppressWarnings("unused") 623 @Override getHotSeatSelector()624 public BySelector getHotSeatSelector() { 625 throw new TvLauncherUnsupportedOperationException("No Hot seat"); 626 } 627 628 @SuppressWarnings("unused") 629 @Override getWorkspaceScrollDirection()630 public Direction getWorkspaceScrollDirection() { 631 throw new TvLauncherUnsupportedOperationException("No Workspace"); 632 } 633 } 634