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.support.test.uiautomator.*; 23 import android.util.Log; 24 25 import java.io.ByteArrayOutputStream; 26 import java.io.IOException; 27 28 public class LeanbackLauncherStrategy implements ILeanbackLauncherStrategy { 29 30 private static final String LOG_TAG = LeanbackLauncherStrategy.class.getSimpleName(); 31 private static final String PACKAGE_LAUNCHER = "com.google.android.leanbacklauncher"; 32 private static final String PACKAGE_SEARCH = "com.google.android.katniss"; 33 34 private static final int MAX_SCROLL_ATTEMPTS = 20; 35 private static final int APP_LAUNCH_TIMEOUT = 10000; 36 private static final int SHORT_WAIT_TIME = 5000; // 5 sec 37 38 protected UiDevice mDevice; 39 40 41 /** 42 * {@inheritDoc} 43 */ 44 @Override getSupportedLauncherPackage()45 public String getSupportedLauncherPackage() { 46 return PACKAGE_LAUNCHER; 47 } 48 49 /** 50 * {@inheritDoc} 51 */ 52 @Override setUiDevice(UiDevice uiDevice)53 public void setUiDevice(UiDevice uiDevice) { 54 mDevice = uiDevice; 55 } 56 57 /** 58 * {@inheritDoc} 59 */ 60 @Override open()61 public void open() { 62 // if we see main list view, assume at home screen already 63 if (!mDevice.hasObject(getWorkspaceSelector())) { 64 mDevice.pressHome(); 65 // ensure launcher is shown 66 if (!mDevice.wait(Until.hasObject(getWorkspaceSelector()), SHORT_WAIT_TIME)) { 67 // HACK: dump hierarchy to logcat 68 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 69 try { 70 mDevice.dumpWindowHierarchy(baos); 71 baos.flush(); 72 baos.close(); 73 String[] lines = baos.toString().split("\\r?\\n"); 74 for (String line : lines) { 75 Log.d(LOG_TAG, line.trim()); 76 } 77 } catch (IOException ioe) { 78 Log.e(LOG_TAG, "error dumping XML to logcat", ioe); 79 } 80 throw new RuntimeException("Failed to open leanback launcher"); 81 } 82 mDevice.waitForIdle(); 83 } 84 } 85 86 /** 87 * {@inheritDoc} 88 */ 89 @Override openAllApps(boolean reset)90 public UiObject2 openAllApps(boolean reset) { 91 UiObject2 appsRow = selectAppsRow(); 92 if (appsRow == null) { 93 throw new RuntimeException("Could not find all apps row"); 94 } 95 if (reset) { 96 Log.w(LOG_TAG, "The reset will be ignored on leanback launcher"); 97 } 98 return appsRow; 99 } 100 101 /** 102 * {@inheritDoc} 103 */ 104 @Override getWorkspaceSelector()105 public BySelector getWorkspaceSelector() { 106 return By.res(getSupportedLauncherPackage(), "main_list_view"); 107 } 108 109 /** 110 * {@inheritDoc} 111 */ 112 @Override getSearchRowSelector()113 public BySelector getSearchRowSelector() { 114 return By.res(getSupportedLauncherPackage(), "search_view"); 115 } 116 117 /** 118 * {@inheritDoc} 119 */ 120 @Override getNotificationRowSelector()121 public BySelector getNotificationRowSelector() { 122 return By.res(getSupportedLauncherPackage(), "notification_view"); 123 } 124 125 /** 126 * {@inheritDoc} 127 */ 128 @Override getAppsRowSelector()129 public BySelector getAppsRowSelector() { 130 return By.res(getSupportedLauncherPackage(), "list").desc("Apps"); 131 } 132 133 /** 134 * {@inheritDoc} 135 */ 136 @Override getGamesRowSelector()137 public BySelector getGamesRowSelector() { 138 return By.res(getSupportedLauncherPackage(), "list").desc("Games"); 139 } 140 141 /** 142 * {@inheritDoc} 143 */ 144 @Override getSettingsRowSelector()145 public BySelector getSettingsRowSelector() { 146 return By.res(getSupportedLauncherPackage(), "list").desc("") 147 .hasDescendant(By.res("icon")); 148 } 149 150 /** 151 * {@inheritDoc} 152 */ 153 @Override getAllAppsScrollDirection()154 public Direction getAllAppsScrollDirection() { 155 return Direction.RIGHT; 156 } 157 158 /** 159 * {@inheritDoc} 160 */ 161 @Override getAllAppsSelector()162 public BySelector getAllAppsSelector() { 163 // On Leanback launcher the Apps row corresponds to the All Apps on phone UI 164 return getAppsRowSelector(); 165 } 166 167 /** 168 * {@inheritDoc} 169 */ 170 @Override launch(String appName, String packageName)171 public long launch(String appName, String packageName) { 172 BySelector app = By.res(getSupportedLauncherPackage(), "app_banner").desc(appName); 173 return launchApp(this, app, packageName); 174 } 175 176 /** 177 * {@inheritDoc} 178 */ 179 @Override search(String query)180 public void search(String query) { 181 if (selectSearchRow() == null) { 182 throw new RuntimeException("Could not find search row."); 183 } 184 185 BySelector keyboardOrb = By.res(getSupportedLauncherPackage(), "keyboard_orb"); 186 UiObject2 orbButton = mDevice.wait(Until.findObject(keyboardOrb), SHORT_WAIT_TIME); 187 if (orbButton == null) { 188 throw new RuntimeException("Could not find keyboard orb."); 189 } 190 if (orbButton.isFocused()) { 191 mDevice.pressDPadCenter(); 192 } else { 193 // Move the focus to keyboard orb by DPad button. 194 mDevice.pressDPadRight(); 195 if (orbButton.isFocused()) { 196 mDevice.pressDPadCenter(); 197 } 198 } 199 mDevice.wait(Until.gone(keyboardOrb), SHORT_WAIT_TIME); 200 201 BySelector searchEditor = By.res(PACKAGE_SEARCH, "search_text_editor"); 202 UiObject2 editText = mDevice.wait(Until.findObject(searchEditor), SHORT_WAIT_TIME); 203 if (editText == null) { 204 throw new RuntimeException("Could not find search text input."); 205 } 206 207 editText.setText(query); 208 SystemClock.sleep(SHORT_WAIT_TIME); 209 210 // Note that Enter key is pressed instead of DPad keys to dismiss leanback IME 211 mDevice.pressEnter(); 212 mDevice.wait(Until.gone(searchEditor), SHORT_WAIT_TIME); 213 } 214 215 /** 216 * {@inheritDoc} 217 * 218 * Assume that the rows are sorted in the following order from the top: 219 * Search, Notification(, Partner), Apps, Games, Settings(, and Inputs) 220 */ 221 @Override selectNotificationRow()222 public UiObject2 selectNotificationRow() { 223 if (!isNotificationRowSelected()) { 224 open(); 225 mDevice.pressHome(); // Home key to move to the first card in the Notification row 226 } 227 return mDevice.wait(Until.findObject( 228 getNotificationRowSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME); 229 } 230 231 /** 232 * {@inheritDoc} 233 */ 234 @Override selectSearchRow()235 public UiObject2 selectSearchRow() { 236 if (!isSearchRowSelected()) { 237 selectNotificationRow(); 238 mDevice.pressDPadUp(); 239 } 240 return mDevice.wait(Until.findObject( 241 getSearchRowSelector().hasDescendant(By.focused(true))), SHORT_WAIT_TIME); 242 } 243 244 /** 245 * {@inheritDoc} 246 */ 247 @Override selectAppsRow()248 public UiObject2 selectAppsRow() { 249 // Start finding Apps row from Notification row 250 if (!isAppsRowSelected()) { 251 selectNotificationRow(); 252 mDevice.pressDPadDown(); 253 } 254 return mDevice.wait(Until.findObject( 255 getAllAppsSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME); 256 } 257 258 /** 259 * {@inheritDoc} 260 */ 261 @Override selectGamesRow()262 public UiObject2 selectGamesRow() { 263 if (!isGamesRowSelected()) { 264 selectAppsRow(); 265 mDevice.pressDPadDown(); 266 // If more than or equal to 16 apps are installed, the app banner could be cut off 267 // into two rows at maximum. It needs to scroll down once more. 268 if (!isGamesRowSelected()) { 269 mDevice.pressDPadDown(); 270 } 271 } 272 return mDevice.wait(Until.findObject( 273 getGamesRowSelector().hasChild(By.focused(true))), SHORT_WAIT_TIME); 274 } 275 276 /** 277 * {@inheritDoc} 278 */ 279 @Override selectSettingsRow()280 public UiObject2 selectSettingsRow() { 281 if (!isSettingsRowSelected()) { 282 open(); 283 mDevice.pressHome(); // Home key to move to the first card in the Notification row 284 // The Settings row is at the last position 285 final int MAX_ROW_NUMS = 8; 286 for (int i = 0; i < MAX_ROW_NUMS; ++i) { 287 mDevice.pressDPadDown(); 288 } 289 } 290 return null; 291 } 292 293 @SuppressWarnings("unused") 294 @Override getAllAppsButtonSelector()295 public BySelector getAllAppsButtonSelector() { 296 throw new UnsupportedOperationException( 297 "The 'All Apps' button is not available on Leanback Launcher."); 298 } 299 300 @SuppressWarnings("unused") 301 @Override openAllWidgets(boolean reset)302 public UiObject2 openAllWidgets(boolean reset) { 303 throw new UnsupportedOperationException( 304 "All Widgets is not available on Leanback Launcher."); 305 } 306 307 @SuppressWarnings("unused") 308 @Override getAllWidgetsSelector()309 public BySelector getAllWidgetsSelector() { 310 throw new UnsupportedOperationException( 311 "All Widgets is not available on Leanback Launcher."); 312 } 313 314 @SuppressWarnings("unused") 315 @Override getAllWidgetsScrollDirection()316 public Direction getAllWidgetsScrollDirection() { 317 throw new UnsupportedOperationException( 318 "All Widgets is not available on Leanback Launcher."); 319 } 320 321 @SuppressWarnings("unused") 322 @Override getHotSeatSelector()323 public BySelector getHotSeatSelector() { 324 throw new UnsupportedOperationException( 325 "Hot Seat is not available on Leanback Launcher."); 326 } 327 328 @SuppressWarnings("unused") 329 @Override getWorkspaceScrollDirection()330 public Direction getWorkspaceScrollDirection() { 331 throw new UnsupportedOperationException( 332 "Workspace is not available on Leanback Launcher."); 333 } 334 launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName)335 protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app, 336 String packageName) { 337 return launchApp(launcherStrategy, app, packageName, MAX_SCROLL_ATTEMPTS); 338 } 339 launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName, int maxScrollAttempts)340 protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app, 341 String packageName, int maxScrollAttempts) { 342 unlockDeviceIfAsleep(); 343 344 if (isAppOpen(packageName)) { 345 // Application is already open 346 return 0; 347 } 348 349 // Go to the home page 350 launcherStrategy.open(); 351 // attempt to find the app icon if it's not already on the screen 352 UiObject2 container = launcherStrategy.openAllApps(false); 353 UiObject2 appIcon = container.findObject(app); 354 int attempts = 0; 355 while (attempts++ < maxScrollAttempts) { 356 // Compare the focused icon and the app icon to search for. 357 UiObject2 focusedIcon = container.findObject(By.focused(true)) 358 .findObject(By.res(getSupportedLauncherPackage(), "app_banner")); 359 360 if (appIcon == null) { 361 appIcon = findApp(container, focusedIcon, app); 362 if (appIcon == null) { 363 throw new RuntimeException("Failed to find the app icon on screen: " 364 + packageName); 365 } 366 continue; 367 } else if (focusedIcon.equals(appIcon)) { 368 // The app icon is on the screen, and selected. 369 break; 370 } else { 371 // The app icon is on the screen, but not selected yet 372 // Move one step closer to the app icon 373 Point currentPosition = focusedIcon.getVisibleCenter(); 374 Point targetPosition = appIcon.getVisibleCenter(); 375 int dx = targetPosition.x - currentPosition.x; 376 int dy = targetPosition.y - currentPosition.y; 377 final int MARGIN = 10; 378 // The sequence of moving should be kept in the following order so as not to 379 // be stuck in case that the apps row are not even. 380 if (dx < -MARGIN) { 381 mDevice.pressDPadLeft(); 382 continue; 383 } 384 if (dy < -MARGIN) { 385 mDevice.pressDPadUp(); 386 continue; 387 } 388 if (dx > MARGIN) { 389 mDevice.pressDPadRight(); 390 continue; 391 } 392 if (dy > MARGIN) { 393 mDevice.pressDPadDown(); 394 continue; 395 } 396 throw new RuntimeException( 397 "Failed to navigate to the app icon on screen: " + packageName); 398 } 399 } 400 401 if (attempts == maxScrollAttempts) { 402 throw new RuntimeException( 403 "scrollBackToBeginning: exceeded max attempts: " + maxScrollAttempts); 404 } 405 406 // The app icon is already found and focused. 407 long ready = SystemClock.uptimeMillis(); 408 mDevice.pressDPadCenter(); 409 mDevice.waitForIdle(); 410 if (packageName != null) { 411 Log.w(LOG_TAG, String.format( 412 "No UI element with package name %s detected.", packageName)); 413 boolean success = mDevice.wait(Until.hasObject( 414 By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT); 415 if (success) { 416 return ready; 417 } else { 418 return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP; 419 } 420 } else { 421 return ready; 422 } 423 } 424 isSearchRowSelected()425 protected boolean isSearchRowSelected() { 426 UiObject2 row = mDevice.findObject(getSearchRowSelector()); 427 if (row == null) { 428 return false; 429 } 430 return row.hasObject(By.focused(true)); 431 } 432 isAppsRowSelected()433 protected boolean isAppsRowSelected() { 434 UiObject2 row = mDevice.findObject(getAppsRowSelector()); 435 if (row == null) { 436 return false; 437 } 438 return row.hasObject(By.focused(true)); 439 } 440 isGamesRowSelected()441 protected boolean isGamesRowSelected() { 442 UiObject2 row = mDevice.findObject(getGamesRowSelector()); 443 if (row == null) { 444 return false; 445 } 446 return row.hasObject(By.focused(true)); 447 } 448 isNotificationRowSelected()449 protected boolean isNotificationRowSelected() { 450 UiObject2 row = mDevice.findObject(getNotificationRowSelector()); 451 if (row == null) { 452 return false; 453 } 454 return row.hasObject(By.focused(true)); 455 } 456 isSettingsRowSelected()457 protected boolean isSettingsRowSelected() { 458 // Settings label is only visible if the settings row is selected 459 return mDevice.hasObject(By.res(getSupportedLauncherPackage(), "label").text("Settings")); 460 } 461 isAppOpen(String appPackage)462 protected boolean isAppOpen (String appPackage) { 463 return mDevice.hasObject(By.pkg(appPackage).depth(0)); 464 } 465 unlockDeviceIfAsleep()466 protected void unlockDeviceIfAsleep () { 467 // Turn screen on if necessary 468 try { 469 if (!mDevice.isScreenOn()) { 470 mDevice.wakeUp(); 471 } 472 } catch (RemoteException e) { 473 Log.e(LOG_TAG, "Failed to unlock the screen-off device.", e); 474 } 475 } 476 findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app)477 protected UiObject2 findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app) { 478 UiObject2 appIcon; 479 // The app icon is not on the screen. 480 // Search by going left first until it finds the app icon on the screen 481 String prevText = focusedIcon.getContentDescription(); 482 String nextText; 483 do { 484 mDevice.pressDPadLeft(); 485 appIcon = container.findObject(app); 486 if (appIcon != null) { 487 return appIcon; 488 } 489 nextText = container.findObject(By.focused(true)).findObject( 490 By.res(getSupportedLauncherPackage(), 491 "app_banner")).getContentDescription(); 492 } while (nextText != null && !nextText.equals(prevText)); 493 494 // If we haven't found it yet, search by going right 495 do { 496 mDevice.pressDPadRight(); 497 appIcon = container.findObject(app); 498 if (appIcon != null) { 499 return appIcon; 500 } 501 nextText = container.findObject(By.focused(true)).findObject( 502 By.res(getSupportedLauncherPackage(), 503 "app_banner")).getContentDescription(); 504 } while (nextText != null && !nextText.equals(prevText)); 505 return null; 506 } 507 } 508