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.support.test.launcherhelper; 18 19 import android.graphics.Point; 20 import android.os.RemoteException; 21 import android.os.SystemClock; 22 import android.platform.test.utils.DPadUtil; 23 import android.support.test.uiautomator.By; 24 import android.support.test.uiautomator.BySelector; 25 import android.support.test.uiautomator.Direction; 26 import android.support.test.uiautomator.UiDevice; 27 import android.support.test.uiautomator.UiObject2; 28 import android.support.test.uiautomator.Until; 29 import android.util.Log; 30 31 import java.io.ByteArrayOutputStream; 32 import java.io.IOException; 33 34 public class LeanbackLauncherStrategy implements ILeanbackLauncherStrategy { 35 36 private static final String LOG_TAG = LeanbackLauncherStrategy.class.getSimpleName(); 37 private static final String PACKAGE_LAUNCHER = "com.google.android.leanbacklauncher"; 38 private static final String PACKAGE_SEARCH = "com.google.android.katniss"; 39 40 private static final int MAX_SCROLL_ATTEMPTS = 20; 41 private static final int APP_LAUNCH_TIMEOUT = 10000; 42 private static final int SHORT_WAIT_TIME = 5000; // 5 sec 43 private static final int NOTIFICATION_WAIT_TIME = 30000; 44 45 protected UiDevice mDevice; 46 protected DPadUtil mDPadUtil; 47 48 49 /** 50 * {@inheritDoc} 51 */ 52 @Override 53 public String getSupportedLauncherPackage() { 54 return PACKAGE_LAUNCHER; 55 } 56 57 /** 58 * {@inheritDoc} 59 */ 60 @Override 61 public void setUiDevice(UiDevice uiDevice) { 62 mDevice = uiDevice; 63 mDPadUtil = new DPadUtil(mDevice); 64 } 65 66 /** 67 * {@inheritDoc} 68 */ 69 @Override 70 public void open() { 71 // if we see main list view, assume at home screen already 72 if (!mDevice.hasObject(getWorkspaceSelector())) { 73 mDPadUtil.pressHome(); 74 // ensure launcher is shown 75 if (!mDevice.wait(Until.hasObject(getWorkspaceSelector()), SHORT_WAIT_TIME)) { 76 // HACK: dump hierarchy to logcat 77 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 78 try { 79 mDevice.dumpWindowHierarchy(baos); 80 baos.flush(); 81 baos.close(); 82 String[] lines = baos.toString().split("\\r?\\n"); 83 for (String line : lines) { 84 Log.d(LOG_TAG, line.trim()); 85 } 86 } catch (IOException ioe) { 87 Log.e(LOG_TAG, "error dumping XML to logcat", ioe); 88 } 89 throw new RuntimeException("Failed to open leanback launcher"); 90 } 91 mDevice.waitForIdle(); 92 } 93 } 94 95 /** 96 * {@inheritDoc} 97 */ 98 @Override 99 public UiObject2 openAllApps(boolean reset) { 100 UiObject2 appsRow = selectAppsRow(); 101 if (appsRow == null) { 102 throw new RuntimeException("Could not find all apps row"); 103 } 104 if (reset) { 105 Log.w(LOG_TAG, "The reset will be ignored on leanback launcher"); 106 } 107 return appsRow; 108 } 109 110 /** 111 * {@inheritDoc} 112 */ 113 @Override 114 public BySelector getWorkspaceSelector() { 115 return By.res(getSupportedLauncherPackage(), "main_list_view"); 116 } 117 118 /** 119 * {@inheritDoc} 120 */ 121 @Override 122 public BySelector getSearchRowSelector() { 123 return By.res(getSupportedLauncherPackage(), "search_view"); 124 } 125 126 /** 127 * {@inheritDoc} 128 */ 129 @Override 130 public BySelector getNotificationRowSelector() { 131 return By.res(getSupportedLauncherPackage(), "notification_view"); 132 } 133 134 /** 135 * {@inheritDoc} 136 */ 137 @Override 138 public BySelector getAppsRowSelector() { 139 return By.res(getSupportedLauncherPackage(), "list").desc("Apps"); 140 } 141 142 /** 143 * {@inheritDoc} 144 */ 145 @Override 146 public BySelector getGamesRowSelector() { 147 return By.res(getSupportedLauncherPackage(), "list").desc("Games"); 148 } 149 150 /** 151 * {@inheritDoc} 152 */ 153 @Override 154 public BySelector getSettingsRowSelector() { 155 return By.res(getSupportedLauncherPackage(), "list").desc("").hasDescendant( 156 By.res(getSupportedLauncherPackage(), "icon"), 3); 157 } 158 159 /** 160 * {@inheritDoc} 161 */ 162 @Override 163 public BySelector getAppWidgetSelector() { 164 return By.clazz(getSupportedLauncherPackage(), "android.appwidget.AppWidgetHostView"); 165 } 166 167 /** 168 * {@inheritDoc} 169 */ 170 @Override 171 public BySelector getNowPlayingCardSelector() { 172 return By.res(getSupportedLauncherPackage(), "content_text").text("Now Playing"); 173 } 174 175 /** 176 * {@inheritDoc} 177 */ 178 @Override 179 public Direction getAllAppsScrollDirection() { 180 return Direction.RIGHT; 181 } 182 183 /** 184 * {@inheritDoc} 185 */ 186 @Override 187 public BySelector getAllAppsSelector() { 188 // On Leanback launcher the Apps row corresponds to the All Apps on phone UI 189 return getAppsRowSelector(); 190 } 191 192 /** 193 * {@inheritDoc} 194 */ 195 @Override 196 public long launch(String appName, String packageName) { 197 BySelector app = By.res(getSupportedLauncherPackage(), "app_banner").desc(appName); 198 return launchApp(this, app, packageName); 199 } 200 201 /** 202 * {@inheritDoc} 203 */ 204 @Override 205 public void search(String query) { 206 if (selectSearchRow() == null) { 207 throw new RuntimeException("Could not find search row."); 208 } 209 210 BySelector keyboardOrb = By.res(getSupportedLauncherPackage(), "keyboard_orb"); 211 UiObject2 orbButton = mDevice.wait(Until.findObject(keyboardOrb), SHORT_WAIT_TIME); 212 if (orbButton == null) { 213 throw new RuntimeException("Could not find keyboard orb."); 214 } 215 if (orbButton.isFocused()) { 216 mDPadUtil.pressDPadCenter(); 217 } else { 218 // Move the focus to keyboard orb by DPad button. 219 mDPadUtil.pressDPadRight(); 220 if (orbButton.isFocused()) { 221 mDPadUtil.pressDPadCenter(); 222 } 223 } 224 mDevice.wait(Until.gone(keyboardOrb), SHORT_WAIT_TIME); 225 226 BySelector searchEditor = By.res(PACKAGE_SEARCH, "search_text_editor"); 227 UiObject2 editText = mDevice.wait(Until.findObject(searchEditor), SHORT_WAIT_TIME); 228 if (editText == null) { 229 throw new RuntimeException("Could not find search text input."); 230 } 231 232 editText.setText(query); 233 SystemClock.sleep(SHORT_WAIT_TIME); 234 235 // Note that Enter key is pressed instead of DPad keys to dismiss leanback IME 236 mDPadUtil.pressEnter(); 237 mDevice.wait(Until.gone(searchEditor), SHORT_WAIT_TIME); 238 } 239 240 /** 241 * {@inheritDoc} 242 * 243 * Assume that the rows are sorted in the following order from the top: 244 * Search, Notification(, Partner), Apps, Games, Settings(, and Inputs) 245 */ 246 @Override 247 public UiObject2 selectNotificationRow() { 248 if (!isNotificationRowSelected()) { 249 open(); 250 mDPadUtil.pressHome(); // Home key to move to the first card in the Notification row 251 } 252 return mDevice.wait(Until.findObject( 253 getNotificationRowSelector().hasDescendant(By.focused(true), 3)), SHORT_WAIT_TIME); 254 } 255 256 /** 257 * {@inheritDoc} 258 */ 259 @Override 260 public UiObject2 selectSearchRow() { 261 if (!isSearchRowSelected()) { 262 selectNotificationRow(); 263 mDPadUtil.pressDPadUp(); 264 } 265 return mDevice.wait(Until.findObject( 266 getSearchRowSelector().hasDescendant(By.focused(true))), SHORT_WAIT_TIME); 267 } 268 269 /** 270 * {@inheritDoc} 271 */ 272 @Override 273 public UiObject2 selectAppsRow() { 274 // Start finding Apps row from Notification row 275 return findRow(getAppsRowSelector()); 276 } 277 278 /** 279 * {@inheritDoc} 280 */ 281 @Override 282 public UiObject2 selectGamesRow() { 283 return findRow(getGamesRowSelector()); 284 } 285 286 /** 287 * {@inheritDoc} 288 */ 289 @Override 290 public UiObject2 selectSettingsRow() { 291 // Assume that the Settings row is at the lowest bottom 292 UiObject2 settings = findRow(getSettingsRowSelector(), Direction.DOWN); 293 if (settings != null && isSettingsRowSelected()) { 294 return settings; 295 } 296 return null; 297 } 298 299 /** 300 * {@inheritDoc} 301 */ 302 @Override 303 public boolean hasAppWidgetSelector() { 304 return mDevice.wait(Until.hasObject(getAppWidgetSelector()), SHORT_WAIT_TIME); 305 } 306 307 /** 308 * {@inheritDoc} 309 */ 310 @Override 311 public boolean hasNowPlayingCard() { 312 return mDevice.wait(Until.hasObject(getNowPlayingCardSelector()), SHORT_WAIT_TIME); 313 } 314 315 @SuppressWarnings("unused") 316 @Override 317 public BySelector getAllAppsButtonSelector() { 318 throw new UnsupportedOperationException( 319 "The 'All Apps' button is not available on Leanback Launcher."); 320 } 321 322 @SuppressWarnings("unused") 323 @Override 324 public UiObject2 openAllWidgets(boolean reset) { 325 throw new UnsupportedOperationException( 326 "All Widgets is not available on Leanback Launcher."); 327 } 328 329 @SuppressWarnings("unused") 330 @Override 331 public BySelector getAllWidgetsSelector() { 332 throw new UnsupportedOperationException( 333 "All Widgets is not available on Leanback Launcher."); 334 } 335 336 @SuppressWarnings("unused") 337 @Override 338 public Direction getAllWidgetsScrollDirection() { 339 throw new UnsupportedOperationException( 340 "All Widgets is not available on Leanback Launcher."); 341 } 342 343 @SuppressWarnings("unused") 344 @Override 345 public BySelector getHotSeatSelector() { 346 throw new UnsupportedOperationException( 347 "Hot Seat is not available on Leanback Launcher."); 348 } 349 350 @SuppressWarnings("unused") 351 @Override 352 public Direction getWorkspaceScrollDirection() { 353 throw new UnsupportedOperationException( 354 "Workspace is not available on Leanback Launcher."); 355 } 356 357 protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app, 358 String packageName) { 359 return launchApp(launcherStrategy, app, packageName, MAX_SCROLL_ATTEMPTS); 360 } 361 362 protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app, 363 String packageName, int maxScrollAttempts) { 364 unlockDeviceIfAsleep(); 365 366 if (isAppOpen(packageName)) { 367 // Application is already open 368 return 0; 369 } 370 371 // Go to the home page 372 launcherStrategy.open(); 373 // attempt to find the app icon if it's not already on the screen 374 UiObject2 container = launcherStrategy.openAllApps(false); 375 UiObject2 appIcon = container.findObject(app); 376 int attempts = 0; 377 while (attempts++ < maxScrollAttempts) { 378 // Compare the focused icon and the app icon to search for. 379 UiObject2 focusedIcon = container.findObject(By.focused(true)) 380 .findObject(By.res(getSupportedLauncherPackage(), "app_banner")); 381 382 if (appIcon == null) { 383 appIcon = findApp(container, focusedIcon, app); 384 if (appIcon == null) { 385 throw new RuntimeException("Failed to find the app icon on screen: " 386 + packageName); 387 } 388 continue; 389 } else if (focusedIcon.equals(appIcon)) { 390 // The app icon is on the screen, and selected. 391 break; 392 } else { 393 // The app icon is on the screen, but not selected yet 394 // Move one step closer to the app icon 395 Point currentPosition = focusedIcon.getVisibleCenter(); 396 Point targetPosition = appIcon.getVisibleCenter(); 397 int dx = targetPosition.x - currentPosition.x; 398 int dy = targetPosition.y - currentPosition.y; 399 final int MARGIN = 10; 400 // The sequence of moving should be kept in the following order so as not to 401 // be stuck in case that the apps row are not even. 402 if (dx < -MARGIN) { 403 mDPadUtil.pressDPadLeft(); 404 continue; 405 } 406 if (dy < -MARGIN) { 407 mDPadUtil.pressDPadUp(); 408 continue; 409 } 410 if (dx > MARGIN) { 411 mDPadUtil.pressDPadRight(); 412 continue; 413 } 414 if (dy > MARGIN) { 415 mDPadUtil.pressDPadDown(); 416 continue; 417 } 418 throw new RuntimeException( 419 "Failed to navigate to the app icon on screen: " + packageName); 420 } 421 } 422 423 if (attempts == maxScrollAttempts) { 424 throw new RuntimeException( 425 "scrollBackToBeginning: exceeded max attempts: " + maxScrollAttempts); 426 } 427 428 // The app icon is already found and focused. 429 long ready = SystemClock.uptimeMillis(); 430 mDPadUtil.pressDPadCenter(); 431 if (!mDevice.wait(Until.hasObject(By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT)) { 432 Log.w(LOG_TAG, "no new window detected after app launch attempt."); 433 return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP; 434 } 435 mDevice.waitForIdle(); 436 if (packageName != null) { 437 Log.w(LOG_TAG, String.format( 438 "No UI element with package name %s detected.", packageName)); 439 boolean success = mDevice.wait(Until.hasObject( 440 By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT); 441 if (success) { 442 return ready; 443 } else { 444 return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP; 445 } 446 } else { 447 return ready; 448 } 449 } 450 451 /** 452 * Launch the named notification 453 * 454 * @param appName - the name of the application to launch in the Notification row 455 * @return true if application is verified to be in foreground after launch; false otherwise. 456 */ 457 public boolean launchNotification(String appName) { 458 // Wait until notification content is loaded 459 long currentTimeMs = System.currentTimeMillis(); 460 while (isNotificationPreparing() && 461 (System.currentTimeMillis() - currentTimeMs > NOTIFICATION_WAIT_TIME)) { 462 Log.d(LOG_TAG, "Preparing recommendation..."); 463 SystemClock.sleep(SHORT_WAIT_TIME); 464 } 465 466 // Find a Notification that matches a given app name 467 UiObject2 card = findNotificationCard( 468 By.res(getSupportedLauncherPackage(), "card").descContains(appName)); 469 if (card == null) { 470 throw new IllegalStateException( 471 String.format("The Notification that matches %s not found", appName)); 472 } 473 Log.d(LOG_TAG, 474 String.format("The application %s found in the Notification row. [content_desc]%s", 475 appName, card.getContentDescription())); 476 477 // Click and wait until the Notification card opens 478 return mDPadUtil.pressDPadCenterAndWait(Until.newWindow(), APP_LAUNCH_TIMEOUT); 479 } 480 481 protected boolean isSearchRowSelected() { 482 UiObject2 row = mDevice.findObject(getSearchRowSelector()); 483 if (row == null) { 484 return false; 485 } 486 return row.hasObject(By.focused(true)); 487 } 488 489 protected boolean isAppsRowSelected() { 490 UiObject2 row = mDevice.findObject(getAppsRowSelector()); 491 if (row == null) { 492 return false; 493 } 494 return row.hasObject(By.focused(true)); 495 } 496 497 protected boolean isGamesRowSelected() { 498 UiObject2 row = mDevice.findObject(getGamesRowSelector()); 499 if (row == null) { 500 return false; 501 } 502 return row.hasObject(By.focused(true)); 503 } 504 505 protected boolean isNotificationRowSelected() { 506 UiObject2 row = mDevice.findObject(getNotificationRowSelector()); 507 if (row == null) { 508 return false; 509 } 510 return row.hasObject(By.focused(true)); 511 } 512 513 protected boolean isSettingsRowSelected() { 514 // Settings label is only visible if the settings row is selected 515 UiObject2 row = mDevice.findObject(getSettingsRowSelector()); 516 return (row != null && row.hasObject( 517 By.res(getSupportedLauncherPackage(), "label").text("Settings"))); 518 } 519 520 protected boolean isAppOpen (String appPackage) { 521 return mDevice.hasObject(By.pkg(appPackage).depth(0)); 522 } 523 524 protected void unlockDeviceIfAsleep () { 525 // Turn screen on if necessary 526 try { 527 if (!mDevice.isScreenOn()) { 528 mDevice.wakeUp(); 529 } 530 } catch (RemoteException e) { 531 Log.e(LOG_TAG, "Failed to unlock the screen-off device.", e); 532 } 533 } 534 535 protected boolean isNotificationPreparing() { 536 // Ensure that the Notification row is visible on screen 537 if (!mDevice.hasObject(getNotificationRowSelector())) { 538 selectNotificationRow(); 539 } 540 return mDevice.hasObject(By.res(getSupportedLauncherPackage(), "notification_preparing")); 541 } 542 543 protected UiObject2 findNotificationCard(BySelector selector) { 544 // Move to the first notification, Search to the right 545 mDPadUtil.pressHome(); 546 547 // Find if a focused card matches a given selector 548 UiObject2 currentFocus = mDevice.findObject(getNotificationRowSelector()) 549 .findObject(By.res(getSupportedLauncherPackage(), "card").focused(true)); 550 UiObject2 previousFocus = null; 551 while (!currentFocus.equals(previousFocus)) { 552 if (currentFocus.hasObject(selector)) { 553 return currentFocus; // Found 554 } 555 mDPadUtil.pressDPadRight(); 556 previousFocus = currentFocus; 557 currentFocus = mDevice.findObject(getNotificationRowSelector()) 558 .findObject(By.res(getSupportedLauncherPackage(), "card").focused(true)); 559 } 560 Log.d(LOG_TAG, "Failed to find the Notification card until it reaches the end."); 561 return null; 562 } 563 564 protected UiObject2 findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app) { 565 UiObject2 appIcon; 566 // The app icon is not on the screen. 567 // Search by going left first until it finds the app icon on the screen 568 String prevText = focusedIcon.getContentDescription(); 569 String nextText; 570 do { 571 mDPadUtil.pressDPadLeft(); 572 appIcon = container.findObject(app); 573 if (appIcon != null) { 574 return appIcon; 575 } 576 nextText = container.findObject(By.focused(true)).findObject( 577 By.res(getSupportedLauncherPackage(), 578 "app_banner")).getContentDescription(); 579 } while (nextText != null && !nextText.equals(prevText)); 580 581 // If we haven't found it yet, search by going right 582 do { 583 mDPadUtil.pressDPadRight(); 584 appIcon = container.findObject(app); 585 if (appIcon != null) { 586 return appIcon; 587 } 588 nextText = container.findObject(By.focused(true)).findObject( 589 By.res(getSupportedLauncherPackage(), 590 "app_banner")).getContentDescription(); 591 } while (nextText != null && !nextText.equals(prevText)); 592 return null; 593 } 594 595 /** 596 * Find the focused row that matches BySelector in a given direction. 597 * If the row is already selected, it returns regardless of the direction parameter. 598 * @param row 599 * @param direction 600 * @return 601 */ 602 protected UiObject2 findRow(BySelector row, Direction direction) { 603 if (direction != Direction.DOWN && direction != Direction.UP) { 604 throw new IllegalArgumentException("Required to go either up or down to find rows"); 605 } 606 607 UiObject2 currentFocused = mDevice.findObject(By.focused(true)); 608 UiObject2 prevFocused = null; 609 while (!currentFocused.equals(prevFocused)) { 610 UiObject2 rowObject = mDevice.findObject(row); 611 if (rowObject != null && rowObject.hasObject(By.focused(true))) { 612 return rowObject; // Found 613 } 614 615 mDPadUtil.pressDPad(direction); 616 prevFocused = currentFocused; 617 currentFocused = mDevice.findObject(By.focused(true)); 618 } 619 Log.d(LOG_TAG, "Failed to find the row until it reaches the end."); 620 return null; 621 } 622 623 protected UiObject2 findRow(BySelector row) { 624 UiObject2 rowObject; 625 // Search by going down first until it finds the focused row. 626 if ((rowObject = findRow(row, Direction.DOWN)) != null) { 627 return rowObject; 628 } 629 // If we haven't found it yet, search by going up 630 if ((rowObject = findRow(row, Direction.UP)) != null) { 631 return rowObject; 632 } 633 return null; 634 } 635 636 public void selectRestrictedProfile() { 637 UiObject2 button = findSettingInRow( 638 By.res(getSupportedLauncherPackage(), "label").text("Restricted Profile"), 639 Direction.RIGHT); 640 if (button == null) { 641 throw new IllegalStateException("Restricted Profile not found on launcher"); 642 } 643 mDPadUtil.pressDPadCenterAndWait(Until.newWindow(), APP_LAUNCH_TIMEOUT); 644 } 645 646 protected UiObject2 findSettingInRow(BySelector selector, Direction direction) { 647 if (direction != Direction.RIGHT && direction != Direction.LEFT) { 648 throw new IllegalArgumentException("Either left or right is allowed"); 649 } 650 if (!isSettingsRowSelected()) { 651 selectSettingsRow(); 652 } 653 654 UiObject2 setting; 655 UiObject2 currentFocused = mDevice.findObject(By.focused(true)); 656 UiObject2 prevFocused = null; 657 while (!currentFocused.equals(prevFocused)) { 658 if ((setting = currentFocused.findObject(selector)) != null) { 659 return setting; 660 } 661 662 mDPadUtil.pressDPad(direction); 663 mDevice.waitForIdle(); 664 prevFocused = currentFocused; 665 currentFocused = mDevice.findObject(By.focused(true)); 666 } 667 Log.d(LOG_TAG, "Failed to find the setting in Settings row."); 668 return null; 669 } 670 } 671