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.autofillservice.cts;
18 
19 import static android.autofillservice.cts.Timeouts.DATASET_PICKER_NOT_SHOWN_NAPTIME_MS;
20 import static android.autofillservice.cts.Timeouts.SAVE_NOT_SHOWN_NAPTIME_MS;
21 import static android.autofillservice.cts.Timeouts.SAVE_TIMEOUT;
22 import static android.autofillservice.cts.Timeouts.UI_DATASET_PICKER_TIMEOUT;
23 import static android.autofillservice.cts.Timeouts.UI_SCREEN_ORIENTATION_TIMEOUT;
24 import static android.autofillservice.cts.Timeouts.UI_TIMEOUT;
25 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_ADDRESS;
26 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD;
27 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS;
28 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_GENERIC;
29 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_PASSWORD;
30 import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_USERNAME;
31 
32 import static com.google.common.truth.Truth.assertThat;
33 import static com.google.common.truth.Truth.assertWithMessage;
34 
35 import android.app.Instrumentation;
36 import android.app.UiAutomation;
37 import android.content.Context;
38 import android.content.res.Resources;
39 import android.graphics.Bitmap;
40 import android.os.SystemClock;
41 import android.service.autofill.SaveInfo;
42 import android.support.test.InstrumentationRegistry;
43 import android.support.test.uiautomator.By;
44 import android.support.test.uiautomator.BySelector;
45 import android.support.test.uiautomator.UiDevice;
46 import android.support.test.uiautomator.UiObject2;
47 import android.support.test.uiautomator.Until;
48 import android.text.Html;
49 import android.util.Log;
50 import android.view.accessibility.AccessibilityEvent;
51 import android.view.accessibility.AccessibilityWindowInfo;
52 
53 import androidx.annotation.NonNull;
54 import androidx.annotation.Nullable;
55 
56 import java.io.ByteArrayOutputStream;
57 import java.io.IOException;
58 import java.util.ArrayList;
59 import java.util.Arrays;
60 import java.util.List;
61 import java.util.concurrent.TimeoutException;
62 
63 /**
64  * Helper for UI-related needs.
65  */
66 final class UiBot {
67 
68     private static final String TAG = "AutoFillCtsUiBot";
69 
70     private static final String RESOURCE_ID_DATASET_PICKER = "autofill_dataset_picker";
71     private static final String RESOURCE_ID_DATASET_HEADER = "autofill_dataset_header";
72     private static final String RESOURCE_ID_SAVE_SNACKBAR = "autofill_save";
73     private static final String RESOURCE_ID_SAVE_ICON = "autofill_save_icon";
74     private static final String RESOURCE_ID_SAVE_TITLE = "autofill_save_title";
75     private static final String RESOURCE_ID_CONTEXT_MENUITEM = "floating_toolbar_menu_item_text";
76     private static final String RESOURCE_ID_SAVE_BUTTON_NO = "autofill_save_no";
77 
78     private static final String RESOURCE_STRING_SAVE_TITLE = "autofill_save_title";
79     private static final String RESOURCE_STRING_SAVE_TITLE_WITH_TYPE =
80             "autofill_save_title_with_type";
81     private static final String RESOURCE_STRING_SAVE_TYPE_PASSWORD = "autofill_save_type_password";
82     private static final String RESOURCE_STRING_SAVE_TYPE_ADDRESS = "autofill_save_type_address";
83     private static final String RESOURCE_STRING_SAVE_TYPE_CREDIT_CARD =
84             "autofill_save_type_credit_card";
85     private static final String RESOURCE_STRING_SAVE_TYPE_USERNAME = "autofill_save_type_username";
86     private static final String RESOURCE_STRING_SAVE_TYPE_EMAIL_ADDRESS =
87             "autofill_save_type_email_address";
88     private static final String RESOURCE_STRING_SAVE_BUTTON_NOT_NOW = "save_password_notnow";
89     private static final String RESOURCE_STRING_SAVE_BUTTON_NO_THANKS = "autofill_save_no";
90 
91     private static final String RESOURCE_STRING_AUTOFILL = "autofill";
92     private static final String RESOURCE_STRING_DATASET_PICKER_ACCESSIBILITY_TITLE =
93             "autofill_picker_accessibility_title";
94     private static final String RESOURCE_STRING_SAVE_SNACKBAR_ACCESSIBILITY_TITLE =
95             "autofill_save_accessibility_title";
96 
97     static final BySelector DATASET_PICKER_SELECTOR = By.res("android", RESOURCE_ID_DATASET_PICKER);
98     private static final BySelector SAVE_UI_SELECTOR = By.res("android", RESOURCE_ID_SAVE_SNACKBAR);
99     private static final BySelector DATASET_HEADER_SELECTOR =
100             By.res("android", RESOURCE_ID_DATASET_HEADER);
101 
102     private static final boolean DUMP_ON_ERROR = true;
103 
104     /** Pass to {@link #setScreenOrientation(int)} to change the display to portrait mode */
105     public static int PORTRAIT = 0;
106 
107     /** Pass to {@link #setScreenOrientation(int)} to change the display to landscape mode */
108     public static int LANDSCAPE = 1;
109 
110     private final UiDevice mDevice;
111     private final Context mContext;
112     private final String mPackageName;
113     private final UiAutomation mAutoman;
114     private final Timeout mDefaultTimeout;
115 
116     private boolean mOkToCallAssertNoDatasets;
117 
UiBot()118     UiBot() {
119         this(UI_TIMEOUT);
120     }
121 
UiBot(Timeout defaultTimeout)122     UiBot(Timeout defaultTimeout) {
123         mDefaultTimeout = defaultTimeout;
124         final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
125         mDevice = UiDevice.getInstance(instrumentation);
126         mContext = instrumentation.getContext();
127         mPackageName = mContext.getPackageName();
128         mAutoman = instrumentation.getUiAutomation();
129     }
130 
reset()131     void reset() {
132         mOkToCallAssertNoDatasets = false;
133     }
134 
getDevice()135     UiDevice getDevice() {
136         return mDevice;
137     }
138 
139     /**
140      * Asserts the dataset picker is not shown anymore.
141      *
142      * @throws IllegalStateException if called *before* an assertion was made to make sure the
143      * dataset picker is shown - if that's not the case, call
144      * {@link #assertNoDatasetsEver()} instead.
145      */
assertNoDatasets()146     void assertNoDatasets() throws Exception {
147         if (!mOkToCallAssertNoDatasets) {
148             throw new IllegalStateException(
149                     "Cannot call assertNoDatasets() without calling assertDatasets first");
150         }
151         mDevice.wait(Until.gone(DATASET_PICKER_SELECTOR), UI_DATASET_PICKER_TIMEOUT.ms());
152         mOkToCallAssertNoDatasets = false;
153     }
154 
155     /**
156      * Asserts the dataset picker was never shown.
157      *
158      * <p>This method is slower than {@link #assertNoDatasets()} and should only be called in the
159      * cases where the dataset picker was not previous shown.
160      */
assertNoDatasetsEver()161     void assertNoDatasetsEver() throws Exception {
162         assertNeverShown("dataset picker", DATASET_PICKER_SELECTOR,
163                 DATASET_PICKER_NOT_SHOWN_NAPTIME_MS);
164     }
165 
166     /**
167      * Asserts the dataset chooser is shown and contains exactly the given datasets.
168      *
169      * @return the dataset picker object.
170      */
assertDatasets(String...names)171     UiObject2 assertDatasets(String...names) throws Exception {
172         // TODO: change run() so it can rethrow the original message
173         return UI_DATASET_PICKER_TIMEOUT.run("assertDatasets: " + Arrays.toString(names), () -> {
174             final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
175             try {
176                 // TODO: use a library to check it contains, instead of asserThat + catch exception
177                 assertWithMessage("wrong dataset names").that(getChildrenAsText(picker))
178                         .containsExactlyElementsIn(Arrays.asList(names)).inOrder();
179                 return picker;
180             } catch (AssertionError e) {
181                 // Value mismatch - most likely UI didn't change yet, try again
182                 Log.w(TAG, "datasets don't match yet: " + e.getMessage());
183                 return null;
184             }
185         });
186     }
187 
188     /**
189      * Asserts the dataset chooser is shown and contains the given datasets.
190      *
191      * @return the dataset picker object.
192      */
assertDatasetsContains(String...names)193     UiObject2 assertDatasetsContains(String...names) throws Exception {
194         // TODO: change run() so it can rethrow the original message
195         return UI_DATASET_PICKER_TIMEOUT.run("assertDatasets: " + Arrays.toString(names), () -> {
196             final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
197             try {
198                 // TODO: use a library to check it contains, instead of asserThat + catch exception
199                 assertWithMessage("wrong dataset names").that(getChildrenAsText(picker))
200                 .containsAllIn(Arrays.asList(names)).inOrder();
201                 return picker;
202             } catch (AssertionError e) {
203                 // Value mismatch - most likely UI didn't change yet, try again
204                 Log.w(TAG, "datasets don't match yet: " + e.getMessage());
205                 return null;
206             }
207         });
208     }
209 
210     /**
211      * Asserts the dataset chooser is shown and contains the given datasets, header, and footer.
212      * <p>In fullscreen, header view is not under R.id.autofill_dataset_picker.
213      *
214      * @return the dataset picker object.
215      */
216     UiObject2 assertDatasetsWithBorders(String header, String footer, String...names)
217             throws Exception {
218         final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
219         final List<String> expectedChild = new ArrayList<>();
220         if (header != null) {
221             if (Helper.isAutofillWindowFullScreen(mContext)) {
222                 final UiObject2 headerView = waitForObject(DATASET_HEADER_SELECTOR,
223                         UI_DATASET_PICKER_TIMEOUT);
224                 assertWithMessage("fullscreen wrong dataset header")
225                         .that(getChildrenAsText(headerView))
226                         .containsExactlyElementsIn(Arrays.asList(header)).inOrder();
227             } else {
228                 expectedChild.add(header);
229             }
230         }
231         expectedChild.addAll(Arrays.asList(names));
232         if (footer != null) {
233             expectedChild.add(footer);
234         }
235         assertWithMessage("wrong elements on dataset picker").that(getChildrenAsText(picker))
236                 .containsExactlyElementsIn(expectedChild).inOrder();
237         return picker;
238     }
239 
240     /**
241      * Gets the text of this object children.
242      */
243     List<String> getChildrenAsText(UiObject2 object) {
244         final List<String> list = new ArrayList<>();
245         getChildrenAsText(object, list);
246         return list;
247     }
248 
249     private static void getChildrenAsText(UiObject2 object, List<String> children) {
250         final String text = object.getText();
251         if (text != null) {
252             children.add(text);
253         }
254         for (UiObject2 child : object.getChildren()) {
255             getChildrenAsText(child, children);
256         }
257     }
258 
259     /**
260      * Selects a dataset that should be visible in the floating UI.
261      */
262     void selectDataset(String name) throws Exception {
263         final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
264         selectDataset(picker, name);
265     }
266 
267     /**
268      * Selects a dataset that should be visible in the floating UI.
269      */
270     void selectDataset(UiObject2 picker, String name) {
271         final UiObject2 dataset = picker.findObject(By.text(name));
272         if (dataset == null) {
273             throw new AssertionError("no dataset " + name + " in " + getChildrenAsText(picker));
274         }
275         dataset.click();
276     }
277 
278     /**
279      * Selects a view by text.
280      *
281      * <p><b>NOTE:</b> when selecting an option in dataset picker is shown, prefer
282      * {@link #selectDataset(String)}.
283      */
284     void selectByText(String name) throws Exception {
285         Log.v(TAG, "selectByText(): " + name);
286 
287         final UiObject2 object = waitForObject(By.text(name));
288         object.click();
289     }
290 
291     /**
292      * Asserts a text is shown.
293      *
294      * <p><b>NOTE:</b> when asserting the dataset picker is shown, prefer
295      * {@link #assertDatasets(String...)}.
296      */
297     public UiObject2 assertShownByText(String text) throws Exception {
298         return assertShownByText(text, mDefaultTimeout);
299     }
300 
301     public UiObject2 assertShownByText(String text, Timeout timeout) throws Exception {
302         final UiObject2 object = waitForObject(By.text(text), timeout);
303         assertWithMessage("No node with text '%s'", text).that(object).isNotNull();
304         return object;
305     }
306 
307     /**
308      * Asserts that the text is not showing for sure in the screen "as is", i.e., without waiting
309      * for it.
310      *
311      * <p>Typically called after another assertion that waits for a condition to be shown.
312      */
313     public void assertNotShowingForSure(String text) throws Exception {
314         final UiObject2 object = mDevice.findObject(By.text(text));
315         assertWithMessage("Find node with text '%s'", text).that(object).isNull();
316     }
317 
318     /**
319      * Asserts a node with the given content description is shown.
320      *
321      */
322     public UiObject2 assertShownByContentDescription(String contentDescription) throws Exception {
323         final UiObject2 object = waitForObject(By.desc(contentDescription));
324         assertWithMessage("No node with content description '%s'", contentDescription).that(object)
325                 .isNotNull();
326         return object;
327     }
328 
329     /**
330      * Checks if a View with a certain text exists.
331      */
332     boolean hasViewWithText(String name) {
333         Log.v(TAG, "hasViewWithText(): " + name);
334 
335         return mDevice.findObject(By.text(name)) != null;
336     }
337 
338     /**
339      * Selects a view by id.
340      */
341     UiObject2 selectByRelativeId(String id) throws Exception {
342         Log.v(TAG, "selectByRelativeId(): " + id);
343         UiObject2 object = waitForObject(By.res(mPackageName, id));
344         object.click();
345         return object;
346     }
347 
348     /**
349      * Asserts the id is shown on the screen.
350      */
351     UiObject2 assertShownById(String id) throws Exception {
352         final UiObject2 object = waitForObject(By.res(id));
353         assertThat(object).isNotNull();
354         return object;
355     }
356 
357     /**
358      * Asserts the id is shown on the screen, using a resource id from the test package.
359      */
360     UiObject2 assertShownByRelativeId(String id) throws Exception {
361         return assertShownByRelativeId(id, mDefaultTimeout);
362     }
363 
364     UiObject2 assertShownByRelativeId(String id, Timeout timeout) throws Exception {
365         final UiObject2 obj = waitForObject(By.res(mPackageName, id), timeout);
366         assertThat(obj).isNotNull();
367         return obj;
368     }
369 
370     /**
371      * Asserts the id is not shown on the screen anymore, using a resource id from the test package.
372      *
373      * <p><b>Note:</b> this method should only called AFTER the id was previously shown, otherwise
374      * it might pass without really asserting anything.
375      */
376     void assertGoneByRelativeId(String id, Timeout timeout) {
377         boolean gone = mDevice.wait(Until.gone(By.res(mPackageName, id)), timeout.ms());
378         if (!gone) {
379             final String message = "Object with id '" + id + "' should be gone after "
380                     + timeout + " ms";
381             dumpScreen(message);
382             throw new RetryableException(message);
383         }
384     }
385 
386     /**
387      * Asserts that a {@code selector} is not showing after {@code timeout} milliseconds.
388      */
389     private void assertNeverShown(String description, BySelector selector, long timeout)
390             throws Exception {
391         SystemClock.sleep(timeout);
392         final UiObject2 object = mDevice.findObject(selector);
393         if (object != null) {
394             throw new AssertionError(
395                     String.format("Should not be showing %s after %dms, but got %s",
396                             description, timeout, getChildrenAsText(object)));
397         }
398     }
399 
400     /**
401      * Gets the text set on a view.
402      */
403     String getTextByRelativeId(String id) throws Exception {
404         return waitForObject(By.res(mPackageName, id)).getText();
405     }
406 
407     /**
408      * Focus in the view with the given resource id.
409      */
410     void focusByRelativeId(String id) throws Exception {
411         waitForObject(By.res(mPackageName, id)).click();
412     }
413 
414     /**
415      * Sets a new text on a view.
416      */
417     void setTextByRelativeId(String id, String newText) throws Exception {
418         waitForObject(By.res(mPackageName, id)).setText(newText);
419     }
420 
421     /**
422      * Asserts the save snackbar is showing and returns it.
423      */
424     UiObject2 assertSaveShowing(int type) throws Exception {
425         return assertSaveShowing(SAVE_TIMEOUT, type);
426     }
427 
428     /**
429      * Asserts the save snackbar is showing and returns it.
430      */
431     UiObject2 assertSaveShowing(Timeout timeout, int type) throws Exception {
432         return assertSaveShowing(null, timeout, type);
433     }
434 
435     /**
436      * Presses the Back button.
437      */
438     void pressBack() {
439         Log.d(TAG, "pressBack()");
440         mDevice.pressBack();
441     }
442 
443     /**
444      * Presses the Home button.
445      */
446     void pressHome() {
447         Log.d(TAG, "pressHome()");
448         mDevice.pressHome();
449     }
450 
451     /**
452      * Asserts the save snackbar is not showing.
453      */
454     void assertSaveNotShowing(int type) throws Exception {
455         assertNeverShown("save UI for type " + type, SAVE_UI_SELECTOR, SAVE_NOT_SHOWN_NAPTIME_MS);
456     }
457 
458     private String getSaveTypeString(int type) {
459         final String typeResourceName;
460         switch (type) {
461             case SAVE_DATA_TYPE_PASSWORD:
462                 typeResourceName = RESOURCE_STRING_SAVE_TYPE_PASSWORD;
463                 break;
464             case SAVE_DATA_TYPE_ADDRESS:
465                 typeResourceName = RESOURCE_STRING_SAVE_TYPE_ADDRESS;
466                 break;
467             case SAVE_DATA_TYPE_CREDIT_CARD:
468                 typeResourceName = RESOURCE_STRING_SAVE_TYPE_CREDIT_CARD;
469                 break;
470             case SAVE_DATA_TYPE_USERNAME:
471                 typeResourceName = RESOURCE_STRING_SAVE_TYPE_USERNAME;
472                 break;
473             case SAVE_DATA_TYPE_EMAIL_ADDRESS:
474                 typeResourceName = RESOURCE_STRING_SAVE_TYPE_EMAIL_ADDRESS;
475                 break;
476             default:
477                 throw new IllegalArgumentException("Unsupported type: " + type);
478         }
479         return getString(typeResourceName);
480     }
481 
482     UiObject2 assertSaveShowing(String description, int... types) throws Exception {
483         return assertSaveShowing(SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, description,
484                 SAVE_TIMEOUT, types);
485     }
486 
487     UiObject2 assertSaveShowing(String description, Timeout timeout, int... types)
488             throws Exception {
489         return assertSaveShowing(SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, description, timeout,
490                 types);
491     }
492 
493     UiObject2 assertSaveShowing(int negativeButtonStyle, String description,
494             int... types) throws Exception {
495         return assertSaveShowing(negativeButtonStyle, description, SAVE_TIMEOUT, types);
496     }
497 
498     UiObject2 assertSaveShowing(int negativeButtonStyle, String description, Timeout timeout,
499             int... types) throws Exception {
500         final UiObject2 snackbar = waitForObject(SAVE_UI_SELECTOR, timeout);
501 
502         final UiObject2 titleView =
503                 waitForObject(snackbar, By.res("android", RESOURCE_ID_SAVE_TITLE), timeout);
504         assertWithMessage("save title (%s) is not shown", RESOURCE_ID_SAVE_TITLE).that(titleView)
505                 .isNotNull();
506 
507         final UiObject2 iconView =
508                 waitForObject(snackbar, By.res("android", RESOURCE_ID_SAVE_ICON), timeout);
509         assertWithMessage("save icon (%s) is not shown", RESOURCE_ID_SAVE_ICON).that(iconView)
510                 .isNotNull();
511 
512         final String actualTitle = titleView.getText();
513         Log.d(TAG, "save title: " + actualTitle);
514 
515         final String serviceLabel = InstrumentedAutoFillService.getServiceLabel();
516         switch (types.length) {
517             case 1:
518                 final String expectedTitle = (types[0] == SAVE_DATA_TYPE_GENERIC)
519                         ? Html.fromHtml(getString(RESOURCE_STRING_SAVE_TITLE,
520                                 serviceLabel), 0).toString()
521                         : Html.fromHtml(getString(RESOURCE_STRING_SAVE_TITLE_WITH_TYPE,
522                                 getSaveTypeString(types[0]), serviceLabel), 0).toString();
523                 assertThat(actualTitle).isEqualTo(expectedTitle);
524                 break;
525             case 2:
526                 // We cannot predict the order...
527                 assertThat(actualTitle).contains(getSaveTypeString(types[0]));
528                 assertThat(actualTitle).contains(getSaveTypeString(types[1]));
529                 break;
530             case 3:
531                 // We cannot predict the order...
532                 assertThat(actualTitle).contains(getSaveTypeString(types[0]));
533                 assertThat(actualTitle).contains(getSaveTypeString(types[1]));
534                 assertThat(actualTitle).contains(getSaveTypeString(types[2]));
535                 break;
536             default:
537                 throw new IllegalArgumentException("Invalid types: " + Arrays.toString(types));
538         }
539 
540         if (description != null) {
541             final UiObject2 saveSubTitle = snackbar.findObject(By.text(description));
542             assertWithMessage("save subtitle(%s)", description).that(saveSubTitle).isNotNull();
543         }
544 
545         final String negativeButtonStringId =
546                 (negativeButtonStyle == SaveInfo.NEGATIVE_BUTTON_STYLE_REJECT)
547                 ? RESOURCE_STRING_SAVE_BUTTON_NOT_NOW
548                 : RESOURCE_STRING_SAVE_BUTTON_NO_THANKS;
549         final String expectedNegativeButtonText = getString(negativeButtonStringId).toUpperCase();
550         final UiObject2 negativeButton = waitForObject(snackbar,
551                 By.res("android", RESOURCE_ID_SAVE_BUTTON_NO), timeout);
552         assertWithMessage("wrong text on negative button")
553                 .that(negativeButton.getText().toUpperCase()).isEqualTo(expectedNegativeButtonText);
554 
555         final String expectedAccessibilityTitle =
556                 getString(RESOURCE_STRING_SAVE_SNACKBAR_ACCESSIBILITY_TITLE);
557         assertAccessibilityTitle(snackbar, expectedAccessibilityTitle);
558 
559         return snackbar;
560     }
561 
562     /**
563      * Taps an option in the save snackbar.
564      *
565      * @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
566      * @param types expected types of save info.
567      */
568     void saveForAutofill(boolean yesDoIt, int... types) throws Exception {
569         final UiObject2 saveSnackBar = assertSaveShowing(
570                 SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, null, types);
571         saveForAutofill(saveSnackBar, yesDoIt);
572     }
573 
574     /**
575      * Taps an option in the save snackbar.
576      *
577      * @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
578      * @param types expected types of save info.
579      */
580     void saveForAutofill(int negativeButtonStyle, boolean yesDoIt, int... types) throws Exception {
581         final UiObject2 saveSnackBar = assertSaveShowing(negativeButtonStyle,null, types);
582         saveForAutofill(saveSnackBar, yesDoIt);
583     }
584 
585     /**
586      * Taps an option in the save snackbar.
587      *
588      * @param saveSnackBar Save snackbar, typically obtained through
589      *            {@link #assertSaveShowing(int)}.
590      * @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
591      */
592     void saveForAutofill(UiObject2 saveSnackBar, boolean yesDoIt) {
593         final String id = yesDoIt ? "autofill_save_yes" : "autofill_save_no";
594 
595         final UiObject2 button = saveSnackBar.findObject(By.res("android", id));
596         assertWithMessage("save button (%s)", id).that(button).isNotNull();
597         button.click();
598     }
599 
600     /**
601      * Gets the AUTOFILL contextual menu by long pressing a text field.
602      *
603      * <p><b>NOTE:</b> this method should only be called in scenarios where we explicitly want to
604      * test the overflow menu. For all other scenarios where we want to test manual autofill, it's
605      * better to call {@code AFM.requestAutofill()} directly, because it's less error-prone and
606      * faster.
607      *
608      * @param id resource id of the field.
609      */
610     UiObject2 getAutofillMenuOption(String id) throws Exception {
611         final UiObject2 field = waitForObject(By.res(mPackageName, id));
612         // TODO: figure out why obj.longClick() doesn't always work
613         field.click(3000);
614 
615         final List<UiObject2> menuItems = waitForObjects(
616                 By.res("android", RESOURCE_ID_CONTEXT_MENUITEM), mDefaultTimeout);
617         final String expectedText = getAutofillContextualMenuTitle();
618         final StringBuffer menuNames = new StringBuffer();
619         for (UiObject2 menuItem : menuItems) {
620             final String menuName = menuItem.getText();
621             if (menuName.equalsIgnoreCase(expectedText)) {
622                 return menuItem;
623             }
624             menuNames.append("'").append(menuName).append("' ");
625         }
626         throw new RetryableException("no '%s' on '%s'", expectedText, menuNames);
627     }
628 
629     String getAutofillContextualMenuTitle() {
630         return getString(RESOURCE_STRING_AUTOFILL);
631     }
632 
633     /**
634      * Gets a string from the Android resources.
635      */
636     private String getString(String id) {
637         final Resources resources = mContext.getResources();
638         final int stringId = resources.getIdentifier(id, "string", "android");
639         return resources.getString(stringId);
640     }
641 
642     /**
643      * Gets a string from the Android resources.
644      */
645     private String getString(String id, Object... formatArgs) {
646         final Resources resources = mContext.getResources();
647         final int stringId = resources.getIdentifier(id, "string", "android");
648         return resources.getString(stringId, formatArgs);
649     }
650 
651     /**
652      * Waits for and returns an object.
653      *
654      * @param selector {@link BySelector} that identifies the object.
655      */
656     private UiObject2 waitForObject(BySelector selector) throws Exception {
657         return waitForObject(selector, mDefaultTimeout);
658     }
659 
660     /**
661      * Waits for and returns an object.
662      *
663      * @param parent where to find the object (or {@code null} to use device's root).
664      * @param selector {@link BySelector} that identifies the object.
665      * @param timeout timeout in ms.
666      * @param dumpOnError whether the window hierarchy should be dumped if the object is not found.
667      */
668     private UiObject2 waitForObject(UiObject2 parent, BySelector selector, Timeout timeout,
669             boolean dumpOnError) throws Exception {
670         // NOTE: mDevice.wait does not work for the save snackbar, so we need a polling approach.
671         try {
672             return timeout.run("waitForObject(" + selector + ")", () -> {
673                 return parent != null
674                         ? parent.findObject(selector)
675                         : mDevice.findObject(selector);
676 
677             });
678         } catch (RetryableException e) {
679             if (dumpOnError) {
680                 dumpScreen("waitForObject() for " + selector + "failed");
681             }
682             throw e;
683         }
684     }
685 
686     private UiObject2 waitForObject(UiObject2 parent, BySelector selector, Timeout timeout)
687             throws Exception {
688         return waitForObject(parent, selector, timeout, DUMP_ON_ERROR);
689     }
690 
691     /**
692      * Waits for and returns an object.
693      *
694      * @param selector {@link BySelector} that identifies the object.
695      * @param timeout timeout in ms
696      */
697     private UiObject2 waitForObject(BySelector selector, Timeout timeout) throws Exception {
698         return waitForObject(null, selector, timeout);
699     }
700 
701     /**
702      * Execute a Runnable and wait for TYPE_WINDOWS_CHANGED or TYPE_WINDOW_STATE_CHANGED.
703      * TODO: No longer need Retry, Refactoring the Timeout (e.g. we probably need two values:
704      * one large timeout value that expects window event, one small value that expect no window
705      * event)
706      */
707     public void waitForWindowChange(Runnable runnable, long timeoutMillis) throws TimeoutException {
708         mAutoman.executeAndWaitForEvent(runnable, (AccessibilityEvent event) -> {
709             switch (event.getEventType()) {
710                 case AccessibilityEvent.TYPE_WINDOWS_CHANGED:
711                 case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
712                     return true;
713             }
714             return false;
715         }, timeoutMillis);
716     }
717 
718     /**
719      * Waits for and returns a list of objects.
720      *
721      * @param selector {@link BySelector} that identifies the object.
722      * @param timeout timeout in ms
723      */
724     private List<UiObject2> waitForObjects(BySelector selector, Timeout timeout) throws Exception {
725         // NOTE: mDevice.wait does not work for the save snackbar, so we need a polling approach.
726         try {
727             return timeout.run("waitForObject(" + selector + ")", () -> {
728                 final List<UiObject2> uiObjects = mDevice.findObjects(selector);
729                 if (uiObjects != null && !uiObjects.isEmpty()) {
730                     return uiObjects;
731                 }
732                 return null;
733 
734             });
735 
736         } catch (RetryableException e) {
737             dumpScreen("waitForObjects() for " + selector + "failed");
738             throw e;
739         }
740     }
741 
742     private UiObject2 findDatasetPicker(Timeout timeout) throws Exception {
743         final UiObject2 picker = waitForObject(DATASET_PICKER_SELECTOR, timeout);
744 
745         final String expectedTitle = getString(RESOURCE_STRING_DATASET_PICKER_ACCESSIBILITY_TITLE);
746         assertAccessibilityTitle(picker, expectedTitle);
747 
748         if (picker != null) {
749             mOkToCallAssertNoDatasets = true;
750         }
751 
752         return picker;
753     }
754 
755     /**
756      * Asserts a given object has the expected accessibility title.
757      */
758     private void assertAccessibilityTitle(UiObject2 object, String expectedTitle) {
759         // TODO: ideally it should get the AccessibilityWindowInfo from the object, but UiAutomator
760         // does not expose that.
761         for (AccessibilityWindowInfo window : mAutoman.getWindows()) {
762             final CharSequence title = window.getTitle();
763             if (title != null && title.toString().equals(expectedTitle)) {
764                 return;
765             }
766         }
767         throw new RetryableException("Title '%s' not found for %s", expectedTitle, object);
768     }
769 
770     /**
771      * Sets the the screen orientation.
772      *
773      * @param orientation typically {@link #LANDSCAPE} or {@link #PORTRAIT}.
774      *
775      * @throws RetryableException if value didn't change.
776      */
777     public void setScreenOrientation(int orientation) throws Exception {
778         mAutoman.setRotation(orientation);
779 
780         UI_SCREEN_ORIENTATION_TIMEOUT.run("setScreenOrientation(" + orientation + ")", () -> {
781             return getScreenOrientation() == orientation ? Boolean.TRUE : null;
782         });
783     }
784 
785     /**
786      * Gets the value of the screen orientation.
787      *
788      * @return typically {@link #LANDSCAPE} or {@link #PORTRAIT}.
789      */
790     public int getScreenOrientation() {
791         return mDevice.getDisplayRotation();
792     }
793 
794     /**
795      * Dumps the current view hierarchy int the output stream.
796      */
797     public void dumpScreen(String cause) {
798         new Exception("dumpScreen(cause=" + cause + ") stacktrace").printStackTrace(System.out);
799         try {
800             try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
801                 mDevice.dumpWindowHierarchy(os);
802                 os.flush();
803                 Log.w(TAG, "Dumping window hierarchy because " + cause);
804                 for (String line : os.toString("UTF-8").split("\n")) {
805                     Log.w(TAG, line);
806                     // Sleep a little bit to avoid logs being ignored due to spam
807                     SystemClock.sleep(100);
808                 }
809             }
810         } catch (IOException e) {
811             // Just ignore it...
812             Log.e(TAG, "exception dumping window hierarchy", e);
813             return;
814         }
815     }
816 
817     // TODO(b/74358143): ideally we should take a screenshot limited by the boundaries of the
818     // activity window, so external elements (such as the clock) are filtered out and don't cause
819     // test flakiness when the contents are compared.
820     public Bitmap takeScreenshot() {
821         final long before = SystemClock.elapsedRealtime();
822         final Bitmap bitmap = mAutoman.takeScreenshot();
823         final long delta = SystemClock.elapsedRealtime() - before;
824         Log.v(TAG, "Screenshot taken in " + delta + "ms");
825         return bitmap;
826     }
827 
828     /**
829      * Asserts the contents of a child element.
830      *
831      * @param parent parent object
832      * @param childId (relative) resource id of the child
833      * @param assertion if {@code null}, asserts the child does not exist; otherwise, asserts the
834      * child with it.
835      */
836     public void assertChild(@NonNull UiObject2 parent, @NonNull String childId,
837             @Nullable Visitor<UiObject2> assertion) {
838         final UiObject2 child = parent.findObject(By.res(mPackageName, childId));
839         if (assertion != null) {
840             assertWithMessage("Didn't find child with id '%s'", childId).that(child).isNotNull();
841             try {
842                 assertion.visit(child);
843             } catch (Throwable t) {
844                 throw new AssertionError("Error on child '" + childId + "'", t);
845             }
846         } else {
847             assertWithMessage("Shouldn't find child with id '%s'", childId).that(child).isNull();
848         }
849     }
850 }
851