• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
getSupportedLauncherPackage()53     public String getSupportedLauncherPackage() {
54         return PACKAGE_LAUNCHER;
55     }
56 
57     /**
58      * {@inheritDoc}
59      */
60     @Override
setUiDevice(UiDevice uiDevice)61     public void setUiDevice(UiDevice uiDevice) {
62         mDevice = uiDevice;
63         mDPadUtil = new DPadUtil(mDevice);
64     }
65 
66     /**
67      * {@inheritDoc}
68      */
69     @Override
open()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
openAllApps(boolean reset)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
getWorkspaceSelector()114     public BySelector getWorkspaceSelector() {
115         return By.res(getSupportedLauncherPackage(), "main_list_view");
116     }
117 
118     /**
119      * {@inheritDoc}
120      */
121     @Override
getSearchRowSelector()122     public BySelector getSearchRowSelector() {
123         return By.res(getSupportedLauncherPackage(), "search_view");
124     }
125 
126     /**
127      * {@inheritDoc}
128      */
129     @Override
getNotificationRowSelector()130     public BySelector getNotificationRowSelector() {
131         return By.res(getSupportedLauncherPackage(), "notification_view");
132     }
133 
134     /**
135      * {@inheritDoc}
136      */
137     @Override
getAppsRowSelector()138     public BySelector getAppsRowSelector() {
139         return By.res(getSupportedLauncherPackage(), "list").desc("Apps");
140     }
141 
142     /**
143      * {@inheritDoc}
144      */
145     @Override
getGamesRowSelector()146     public BySelector getGamesRowSelector() {
147         return By.res(getSupportedLauncherPackage(), "list").desc("Games");
148     }
149 
150     /**
151      * {@inheritDoc}
152      */
153     @Override
getSettingsRowSelector()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
getAppWidgetSelector()163     public BySelector getAppWidgetSelector() {
164         return By.clazz(getSupportedLauncherPackage(), "android.appwidget.AppWidgetHostView");
165     }
166 
167     /**
168      * {@inheritDoc}
169      */
170     @Override
getNowPlayingCardSelector()171     public BySelector getNowPlayingCardSelector() {
172         return By.res(getSupportedLauncherPackage(), "content_text").text("Now Playing");
173     }
174 
175     /**
176      * {@inheritDoc}
177      */
178     @Override
getAllAppsScrollDirection()179     public Direction getAllAppsScrollDirection() {
180         return Direction.RIGHT;
181     }
182 
183     /**
184      * {@inheritDoc}
185      */
186     @Override
getAllAppsSelector()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
launch(String appName, String packageName)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
search(String query)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
selectNotificationRow()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
selectSearchRow()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
selectAppsRow()273     public UiObject2 selectAppsRow() {
274         // Start finding Apps row from Notification row
275         return findRow(getAppsRowSelector());
276     }
277 
278     /**
279      * {@inheritDoc}
280      */
281     @Override
selectGamesRow()282     public UiObject2 selectGamesRow() {
283         return findRow(getGamesRowSelector());
284     }
285 
286     /**
287      * {@inheritDoc}
288      */
289     @Override
selectSettingsRow()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
hasAppWidgetSelector()303     public boolean hasAppWidgetSelector() {
304         return mDevice.wait(Until.hasObject(getAppWidgetSelector()), SHORT_WAIT_TIME);
305     }
306 
307     /**
308      * {@inheritDoc}
309      */
310     @Override
hasNowPlayingCard()311     public boolean hasNowPlayingCard() {
312         return mDevice.wait(Until.hasObject(getNowPlayingCardSelector()), SHORT_WAIT_TIME);
313     }
314 
315     @SuppressWarnings("unused")
316     @Override
getAllAppsButtonSelector()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
openAllWidgets(boolean reset)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
getAllWidgetsSelector()331     public BySelector getAllWidgetsSelector() {
332         throw new UnsupportedOperationException(
333                 "All Widgets is not available on Leanback Launcher.");
334     }
335 
336     @SuppressWarnings("unused")
337     @Override
getAllWidgetsScrollDirection()338     public Direction getAllWidgetsScrollDirection() {
339         throw new UnsupportedOperationException(
340                 "All Widgets is not available on Leanback Launcher.");
341     }
342 
343     @SuppressWarnings("unused")
344     @Override
getHotSeatSelector()345     public BySelector getHotSeatSelector() {
346         throw new UnsupportedOperationException(
347                 "Hot Seat is not available on Leanback Launcher.");
348     }
349 
350     @SuppressWarnings("unused")
351     @Override
getWorkspaceScrollDirection()352     public Direction getWorkspaceScrollDirection() {
353         throw new UnsupportedOperationException(
354                 "Workspace is not available on Leanback Launcher.");
355     }
356 
launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName)357     protected long launchApp(ILauncherStrategy launcherStrategy, BySelector app,
358             String packageName) {
359         return launchApp(launcherStrategy, app, packageName, MAX_SCROLL_ATTEMPTS);
360     }
361 
launchApp(ILauncherStrategy launcherStrategy, BySelector app, String packageName, int maxScrollAttempts)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      */
launchNotification(String appName)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 
isSearchRowSelected()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 
isAppsRowSelected()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 
isGamesRowSelected()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 
isNotificationRowSelected()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 
isSettingsRowSelected()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 
isAppOpen(String appPackage)520     protected boolean isAppOpen (String appPackage) {
521         return mDevice.hasObject(By.pkg(appPackage).depth(0));
522     }
523 
unlockDeviceIfAsleep()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 
isNotificationPreparing()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 
findNotificationCard(BySelector selector)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 
findApp(UiObject2 container, UiObject2 focusedIcon, BySelector app)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      */
findRow(BySelector row, Direction direction)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 
findRow(BySelector row)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 
selectRestrictedProfile()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 
findSettingInRow(BySelector selector, Direction direction)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