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
openAllWidgets(boolean reset)295     public UiObject2 openAllWidgets(boolean reset) {
296         throw new UnsupportedOperationException(
297                 "All Widgets is not available on Leanback Launcher.");
298     }
299 
300     @SuppressWarnings("unused")
301     @Override
getAllWidgetsSelector()302     public BySelector getAllWidgetsSelector() {
303         throw new UnsupportedOperationException(
304                 "All Widgets is not available on Leanback Launcher.");
305     }
306 
307     @SuppressWarnings("unused")
308     @Override
getAllWidgetsScrollDirection()309     public Direction getAllWidgetsScrollDirection() {
310         throw new UnsupportedOperationException(
311                 "All Widgets is not available on Leanback Launcher.");
312     }
313 
314     @SuppressWarnings("unused")
315     @Override
getHotSeatSelector()316     public BySelector getHotSeatSelector() {
317         throw new UnsupportedOperationException(
318                 "Hot Seat is not available on Leanback Launcher.");
319     }
320 
321     @SuppressWarnings("unused")
322     @Override
getWorkspaceScrollDirection()323     public Direction getWorkspaceScrollDirection() {
324         throw new UnsupportedOperationException(
325                 "Workspace is not available on Leanback Launcher.");
326     }
327 
launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName)328     protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app,
329             String packageName) {
330         return launchApp(launcherStrategy, app, packageName, MAX_SCROLL_ATTEMPTS);
331     }
332 
launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName, int maxScrollAttempts)333     protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app,
334             String packageName, int maxScrollAttempts) {
335         unlockDeviceIfAsleep();
336 
337         if (isAppOpen(packageName)) {
338             // Application is already open
339             return 0;
340         }
341 
342         // Go to the home page
343         launcherStrategy.open();
344         // attempt to find the app icon if it's not already on the screen
345         UiObject2 container = launcherStrategy.openAllApps(false);
346         UiObject2 appIcon = container.findObject(app);
347         int attempts = 0;
348         while (attempts++ < maxScrollAttempts) {
349             // Compare the focused icon and the app icon to search for.
350             UiObject2 focusedIcon = container.findObject(By.focused(true))
351                     .findObject(By.res(getSupportedLauncherPackage(), "app_banner"));
352 
353             if (appIcon == null) {
354                 appIcon = findApp(container, focusedIcon, app);
355                 if (appIcon == null) {
356                     throw new RuntimeException("Failed to find the app icon on screen: "
357                             + packageName);
358                 }
359                 continue;
360             } else if (focusedIcon.equals(appIcon)) {
361                 // The app icon is on the screen, and selected.
362                 break;
363             } else {
364                 // The app icon is on the screen, but not selected yet
365                 // Move one step closer to the app icon
366                 Point currentPosition = focusedIcon.getVisibleCenter();
367                 Point targetPosition = appIcon.getVisibleCenter();
368                 int dx = targetPosition.x - currentPosition.x;
369                 int dy = targetPosition.y - currentPosition.y;
370                 final int MARGIN = 10;
371                 // The sequence of moving should be kept in the following order so as not to
372                 // be stuck in case that the apps row are not even.
373                 if (dx < -MARGIN) {
374                     mDevice.pressDPadLeft();
375                     continue;
376                 }
377                 if (dy < -MARGIN) {
378                     mDevice.pressDPadUp();
379                     continue;
380                 }
381                 if (dx > MARGIN) {
382                     mDevice.pressDPadRight();
383                     continue;
384                 }
385                 if (dy > MARGIN) {
386                     mDevice.pressDPadDown();
387                     continue;
388                 }
389                 throw new RuntimeException(
390                         "Failed to navigate to the app icon on screen: " + packageName);
391             }
392         }
393 
394         if (attempts == maxScrollAttempts) {
395             throw new RuntimeException(
396                     "scrollBackToBeginning: exceeded max attempts: " + maxScrollAttempts);
397         }
398 
399         // The app icon is already found and focused.
400         long ready = SystemClock.uptimeMillis();
401         mDevice.pressDPadCenter();
402         mDevice.waitForIdle();
403         if (packageName != null) {
404             Log.w(LOG_TAG, String.format(
405                     "No UI element with package name %s detected.", packageName));
406             boolean success = mDevice.wait(Until.hasObject(
407                     By.pkg(packageName).depth(0)), APP_LAUNCH_TIMEOUT);
408             if (success) {
409                 return ready;
410             } else {
411                 return ILauncherStrategy.LAUNCH_FAILED_TIMESTAMP;
412             }
413         } else {
414             return ready;
415         }
416     }
417 
isSearchRowSelected()418     protected boolean isSearchRowSelected() {
419         UiObject2 row = mDevice.findObject(getSearchRowSelector());
420         if (row == null) {
421             return false;
422         }
423         return row.hasObject(By.focused(true));
424     }
425 
isAppsRowSelected()426     protected boolean isAppsRowSelected() {
427         UiObject2 row = mDevice.findObject(getAppsRowSelector());
428         if (row == null) {
429             return false;
430         }
431         return row.hasObject(By.focused(true));
432     }
433 
isGamesRowSelected()434     protected boolean isGamesRowSelected() {
435         UiObject2 row = mDevice.findObject(getGamesRowSelector());
436         if (row == null) {
437             return false;
438         }
439         return row.hasObject(By.focused(true));
440     }
441 
isNotificationRowSelected()442     protected boolean isNotificationRowSelected() {
443         UiObject2 row = mDevice.findObject(getNotificationRowSelector());
444         if (row == null) {
445             return false;
446         }
447         return row.hasObject(By.focused(true));
448     }
449 
isSettingsRowSelected()450     protected boolean isSettingsRowSelected() {
451         // Settings label is only visible if the settings row is selected
452         return mDevice.hasObject(By.res(getSupportedLauncherPackage(), "label").text("Settings"));
453     }
454 
isAppOpen(String appPackage)455     protected boolean isAppOpen (String appPackage) {
456         return mDevice.hasObject(By.pkg(appPackage).depth(0));
457     }
458 
unlockDeviceIfAsleep()459     protected void unlockDeviceIfAsleep () {
460         // Turn screen on if necessary
461         try {
462             if (!mDevice.isScreenOn()) {
463                 mDevice.wakeUp();
464             }
465         } catch (RemoteException e) {
466             Log.e(LOG_TAG, "Failed to unlock the screen-off device.", e);
467         }
468     }
469 
findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app)470     protected UiObject2 findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app) {
471         UiObject2 appIcon;
472         // The app icon is not on the screen.
473         // Search by going left first until it finds the app icon on the screen
474         String prevText = focusedIcon.getContentDescription();
475         String nextText;
476         do {
477             mDevice.pressDPadLeft();
478             appIcon = container.findObject(app);
479             if (appIcon != null) {
480                 return appIcon;
481             }
482             nextText = container.findObject(By.focused(true)).findObject(
483                     By.res(getSupportedLauncherPackage(),
484                             "app_banner")).getContentDescription();
485         } while (nextText != null && !nextText.equals(prevText));
486 
487         // If we haven't found it yet, search by going right
488         do {
489             mDevice.pressDPadRight();
490             appIcon = container.findObject(app);
491             if (appIcon != null) {
492                 return appIcon;
493             }
494             nextText = container.findObject(By.focused(true)).findObject(
495                     By.res(getSupportedLauncherPackage(),
496                             "app_banner")).getContentDescription();
497         } while (nextText != null && !nextText.equals(prevText));
498         return null;
499     }
500 }
501