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 com.android.cts.mockime;
18 
19 import android.graphics.Rect;
20 import android.os.Parcel;
21 import android.os.Parcelable;
22 import android.os.SystemClock;
23 import android.text.TextUtils;
24 import android.util.Pair;
25 import android.view.inputmethod.EditorInfo;
26 import android.view.inputmethod.InputBinding;
27 
28 import androidx.annotation.NonNull;
29 import androidx.annotation.Nullable;
30 import androidx.window.extensions.layout.DisplayFeature;
31 import androidx.window.extensions.layout.FoldingFeature;
32 import androidx.window.extensions.layout.WindowLayoutInfo;
33 
34 import org.junit.Assert;
35 
36 import java.util.ArrayList;
37 import java.util.List;
38 import java.util.Optional;
39 import java.util.concurrent.TimeoutException;
40 import java.util.function.Predicate;
41 
42 /**
43  * A set of utility methods to avoid boilerplate code when writing end-to-end tests.
44  */
45 public final class ImeEventStreamTestUtils {
46     private static final long TIME_SLICE = 50;  // msec
47 
48     /**
49      * Cannot be instantiated
50      */
ImeEventStreamTestUtils()51     private ImeEventStreamTestUtils() {}
52 
53     /**
54      * Behavior mode of {@link #expectEvent(ImeEventStream, Predicate, EventFilterMode, long)}
55      */
56     public enum EventFilterMode {
57         /**
58          * All {@link ImeEvent} events should be checked
59          */
60         CHECK_ALL,
61         /**
62          * Only events that return {@code true} from {@link ImeEvent#isEnterEvent()} should be
63          * checked
64          */
65         CHECK_ENTER_EVENT_ONLY,
66         /**
67          * Only events that return {@code false} from {@link ImeEvent#isEnterEvent()} should be
68          * checked
69          */
70         CHECK_EXIT_EVENT_ONLY;
71 
combine(Predicate<ImeEvent> predicate)72         private Predicate<ImeEvent> combine(Predicate<ImeEvent> predicate) {
73             switch (this) {
74                 case CHECK_ALL:
75                     return predicate;
76                 case CHECK_ENTER_EVENT_ONLY:
77                     return withDescription(predicate + " (ENTER_ONLY)",
78                             event -> event.isEnterEvent() && predicate.test(event));
79                 case CHECK_EXIT_EVENT_ONLY:
80                     return withDescription(predicate + " (EXIT_ONLY)",
81                             event -> !event.isEnterEvent() && predicate.test(event));
82                 default:
83                     throw new IllegalArgumentException("Unknown filter mode: " + this);
84             }
85         }
86     }
87 
88     /**
89      * Wait until an event that matches the given {@code condition} is found in the stream.
90      *
91      * <p>When this method succeeds to find an event that matches the given {@code condition}, the
92      * stream position will be set to the next to the found object then the event found is returned.
93      * </p>
94      *
95      * <p>For convenience, this method automatically filter out exit events (events that return
96      * {@code false} from {@link ImeEvent#isEnterEvent()}.</p>
97      *
98      * <p>TODO: Consider renaming this to {@code expectEventEnter} or something like that.</p>
99      *
100      * @param stream {@link ImeEventStream} to be checked.
101      * @param condition the event condition to be matched
102      * @param timeout timeout in millisecond
103      * @return {@link ImeEvent} found
104      * @throws TimeoutException when the no event is matched to the given condition within
105      *                          {@code timeout}
106      */
107     @NonNull
expectEvent(@onNull ImeEventStream stream, @NonNull Predicate<ImeEvent> condition, long timeout)108     public static ImeEvent expectEvent(@NonNull ImeEventStream stream,
109             @NonNull Predicate<ImeEvent> condition, long timeout) throws TimeoutException {
110         return expectEvent(stream, condition, EventFilterMode.CHECK_ENTER_EVENT_ONLY, timeout);
111     }
112 
113     /**
114      * Wait until an event that matches the given {@code condition} is found in the stream.
115      *
116      * <p>When this method succeeds to find an event that matches the given {@code condition}, the
117      * stream position will be set to the next to the found object then the event found is returned.
118      * </p>
119      *
120      * @param stream {@link ImeEventStream} to be checked.
121      * @param condition the event condition to be matched
122      * @param filterMode controls how events are filtered out
123      * @param timeout timeout in millisecond
124      * @return {@link ImeEvent} found
125      * @throws TimeoutException when the no event is matched to the given condition within
126      *                          {@code timeout}
127      */
128     @NonNull
expectEvent(@onNull ImeEventStream stream, @NonNull Predicate<ImeEvent> condition, EventFilterMode filterMode, long timeout)129     public static ImeEvent expectEvent(@NonNull ImeEventStream stream,
130             @NonNull Predicate<ImeEvent> condition, EventFilterMode filterMode, long timeout)
131             throws TimeoutException {
132         final var combinedCondition = filterMode.combine(condition);
133         try {
134             while (true) {
135                 if (timeout < 0) {
136                     throw new TimeoutException(
137                             "event " + combinedCondition + " not found within the timeout: "
138                                     + stream.dump());
139                 }
140                 Optional<ImeEvent> result = stream.seekToFirst(combinedCondition);
141                 if (result.isPresent()) {
142                     stream.skip(1);
143                     return result.get();
144                 }
145                 Thread.sleep(TIME_SLICE);
146                 timeout -= TIME_SLICE;
147             }
148         } catch (InterruptedException e) {
149             throw new RuntimeException(
150                     "expectEvent " + combinedCondition + " interrupted: " + stream.dump(), e);
151         }
152     }
153 
154     /** Checks if {@code eventName} has occurred. */
eventMatcher(@onNull String eventName)155     public static DescribedPredicate<ImeEvent> eventMatcher(@NonNull String eventName) {
156         return withDescription(eventName + "()",
157                 event -> event.getEventName().equals(eventName));
158     }
159 
160     /**
161      * Checks if {@code eventName} has occurred on the EditText(or TextView) of the current
162      * activity.
163      * @param eventName event name to check
164      * @param marker Test marker set to {@link android.widget.EditText#setPrivateImeOptions(String)}
165      * @return true if event occurred.
166      */
editorMatcher( @onNull String eventName, @NonNull String marker)167     public static DescribedPredicate<ImeEvent> editorMatcher(
168             @NonNull String eventName, @NonNull String marker) {
169         return withDescription(eventName + "(marker=" + marker + ")", event -> {
170             if (!TextUtils.equals(eventName, event.getEventName())) {
171                 return false;
172             }
173             final EditorInfo editorInfo = event.getArguments().getParcelable("editorInfo");
174             return TextUtils.equals(marker, editorInfo.privateImeOptions);
175         });
176     }
177 
178     /**
179      * Returns a matcher to check if the {@code name} is from
180      * {@code MockIme.Tracer#onVerify(String, BooleanSupplier)}
181      */
182     public static DescribedPredicate<ImeEvent> verificationMatcher(@NonNull String name) {
183         return withDescription("onVerify(name=" + name + ")",
184                 event -> "onVerify".equals(event.getEventName())
185                         && name.equals(event.getArguments().getString("name")));
186     }
187 
188     /**
189      * Checks if {@code eventName} has occurred on the EditText(or TextView) of the current
190      * activity mainly for onStartInput restarting check.
191      * @param eventName event name to check
192      * @param marker Test marker set to {@link android.widget.EditText#setPrivateImeOptions(String)}
193      * @return true if event occurred and restarting is false.
194      */
195     public static DescribedPredicate<ImeEvent> editorMatcherRestartingFalse(
196             @NonNull String eventName, @NonNull String marker) {
197         return editorMatcherRestarting(eventName, marker, false);
198     }
199 
200 
201     /**
202      * Checks if {@code eventName} has occurred on the EditText(or TextView) of the current
203      * activity mainly for onStartInput restarting check.
204      * @param eventName event name to check
205      * @param marker Test marker set to {@link android.widget.EditText#setPrivateImeOptions(String)}
206      * @param restarting the expected value of {@code restarting} to match
207      */
208     public static DescribedPredicate<ImeEvent> editorMatcherRestarting(
209             @NonNull String eventName, @NonNull String marker, boolean restarting) {
210         return withDescription(eventName + "(marker=" + marker + ", restarting=" + restarting +")", event -> {
211             if (!TextUtils.equals(eventName, event.getEventName())) {
212                 return false;
213             }
214             final EditorInfo editorInfo = event.getArguments().getParcelable("editorInfo");
215             final boolean actualRestarting = event.getArguments().getBoolean("restarting");
216             return (TextUtils.equals(marker, editorInfo.privateImeOptions)
217                     && restarting == actualRestarting);
218         });
219     }
220 
221     /**
222     * Checks if {@code eventName} has occurred on the EditText(or TextView) of the current
223     * activity.
224     * @param eventName event name to check
225     * @param fieldId typically same as {@link android.view.View#getId()}.
226     * @return true if event occurred.
227     */
228     public static DescribedPredicate<ImeEvent> editorMatcher(@NonNull String eventName, int fieldId) {
229         return withDescription(eventName + "(fieldId=" + fieldId + ")", event -> {
230             if (!TextUtils.equals(eventName, event.getEventName())) {
231                 return false;
232             }
233             final EditorInfo editorInfo = event.getArguments().getParcelable("editorInfo");
234             return fieldId == editorInfo.fieldId;
235         });
236     }
237 
238     public static DescribedPredicate<ImeEvent> showSoftInputMatcher(int requiredFlags) {
239         return withDescription("showSoftInput(requiredFlags=" + requiredFlags + ")", event -> {
240             if (!TextUtils.equals("showSoftInput", event.getEventName())) {
241                 return false;
242             }
243             final int flags = event.getArguments().getInt("flags");
244             return (flags & requiredFlags) == requiredFlags;
245         });
246     }
247 
248     public static DescribedPredicate<ImeEvent> hideSoftInputMatcher() {
249         return withDescription("hideSoftInput",
250                 event -> TextUtils.equals("hideSoftInput", event.getEventName()));
251     }
252 
253     /**
254      * Wait until an event that matches the given command is consumed by the {@link MockIme}.
255      *
256      * <p>For convenience, this method automatically filter out enter events (events that return
257      * {@code true} from {@link ImeEvent#isEnterEvent()}.</p>
258      *
259      * <p>TODO: Consider renaming this to {@code expectCommandConsumed} or something like that.</p>
260      *
261      * @param stream {@link ImeEventStream} to be checked.
262      * @param command {@link ImeCommand} to be waited for.
263      * @param timeout timeout in millisecond
264      * @return {@link ImeEvent} found
265      * @throws TimeoutException when the no event is matched to the given condition within
266      *                          {@code timeout}
267      */
268     @NonNull
269     public static ImeEvent expectCommand(@NonNull ImeEventStream stream,
270             @NonNull ImeCommand command, long timeout) throws TimeoutException {
271         var predicate = withDescription(
272                 "onHandleCommand(id=" + command.getId() + ")", event -> {
273                     if (!TextUtils.equals("onHandleCommand", event.getEventName())) {
274                         return false;
275                     }
276                     final ImeCommand eventCommand =
277                             ImeCommand.fromBundle(event.getArguments().getBundle("command"));
278                     return eventCommand.getId() == command.getId();
279                 });
280         return expectEvent(stream, predicate, EventFilterMode.CHECK_EXIT_EVENT_ONLY, timeout);
281     }
282 
283     /**
284      * Assert that an event that matches the given {@code condition} will no be found in the stream
285      * within the given {@code timeout}.
286      *
287      * <p>When this method succeeds, the stream position will not change.</p>
288      *
289      * <p>For convenience, this method automatically filter out exit events (events that return
290      * {@code false} from {@link ImeEvent#isEnterEvent()}.</p>
291      *
292      * <p>TODO: Consider renaming this to {@code notExpectEventEnter} or something like that.</p>
293      *
294      * @param stream {@link ImeEventStream} to be checked.
295      * @param condition the event condition to be matched
296      * @param timeout timeout in millisecond
297      * @throws AssertionError if such an event is found within the given {@code timeout}
298      */
299     public static void notExpectEvent(@NonNull ImeEventStream stream,
300             @NonNull Predicate<ImeEvent> condition, long timeout) {
301         notExpectEvent(stream, condition, EventFilterMode.CHECK_ENTER_EVENT_ONLY, timeout);
302     }
303 
304     /**
305      * Assert that an event that matches the given {@code condition} will no be found in the stream
306      * within the given {@code timeout}.
307      *
308      * <p>When this method succeeds, the stream position will not change.</p>
309      *
310      * @param stream {@link ImeEventStream} to be checked.
311      * @param condition the event condition to be matched
312      * @param filterMode controls how events are filtered out
313      * @param timeout timeout in millisecond
314      * @throws AssertionError if such an event is found within the given {@code timeout}
315      */
316     public static void notExpectEvent(@NonNull ImeEventStream stream,
317             @NonNull Predicate<ImeEvent> condition, EventFilterMode filterMode, long timeout) {
318         final var combinedCondition = filterMode.combine(condition);
319         try {
320             while (true) {
321                 if (timeout < 0) {
322                     return;
323                 }
324                 if (stream.findFirst(combinedCondition).isPresent()) {
325                     throw new AssertionError(
326                             "notExpectEvent " + combinedCondition + " failed: " + stream.dump());
327                 }
328                 Thread.sleep(TIME_SLICE);
329                 timeout -= TIME_SLICE;
330             }
331         } catch (InterruptedException e) {
332             throw new RuntimeException(
333                     "notExpectEvent " + combinedCondition + " failed: " + stream.dump(), e);
334         }
335     }
336 
337     /**
338      * A specialized version of {@link #expectEvent(ImeEventStream, Predicate, long)} to wait for
339      * {@link android.view.inputmethod.InputMethod#bindInput(InputBinding)}.
340      *
341      * @param stream {@link ImeEventStream} to be checked.
342      * @param targetProcessPid PID to be matched to {@link InputBinding#getPid()}
343      * @param timeout timeout in millisecond
344      * @throws TimeoutException when "bindInput" is not called within {@code timeout} msec
345      */
346     public static void expectBindInput(@NonNull ImeEventStream stream, int targetProcessPid,
347             long timeout) throws TimeoutException {
348         expectEvent(stream, withDescription("bindInput(pid=" + targetProcessPid + ")",
349                 event -> {
350                     if (!TextUtils.equals("bindInput", event.getEventName())) {
351                         return false;
352                     }
353                     final InputBinding binding = event.getArguments().getParcelable("binding");
354                     return binding.getPid() == targetProcessPid;
355                 }), EventFilterMode.CHECK_EXIT_EVENT_ONLY, timeout);
356     }
357 
358     /**
359      * Checks if {@code eventName} has occurred and given {@param key} has value {@param value}.
360      * @param eventName event name to check.
361      * @param key the key that should be checked.
362      * @param value the expected value for the given {@param key}.
363      */
364     public static void expectEventWithKeyValue(@NonNull ImeEventStream stream,
365             @NonNull String eventName, @NonNull String key, int value, long timeout)
366             throws TimeoutException {
367         var condition = withDescription(eventName + "(" + key + "=" + value + ")",
368                 event -> TextUtils.equals(eventName, event.getEventName())
369                         && value == event.getArguments().getInt(key));
370         expectEvent(stream, condition, timeout);
371     }
372 
373     /**
374      * Assert that the {@link MockIme} will not be terminated abruptly with executing a command to
375      * check if it's still alive and verify the number of create/destroy callback should be paired.
376      *
377      * @param session {@link MockImeSession} to be checked.
378      * @param timeout timeout in millisecond to check if {@link MockIme} is still alive.
379      * @throws Exception
380      */
381     public static void expectNoImeCrash(@NonNull MockImeSession session, long timeout)
382             throws Exception {
383         // Issue any trivial command to make sure that the MockIme is still alive.
384         final ImeCommand command = session.callGetDisplayId();
385         expectCommand(session.openEventStream(), command, timeout);
386         // A filter that matches exit events of "onCreate", "onDestroy", and the *command* above.
387         final Predicate<ImeEvent> matcher = event -> {
388             if (!event.isEnterEvent()) {
389                 return false;
390             }
391             switch (event.getEventName()) {
392                 case "onHandleCommand": {
393                     final ImeCommand eventCommand =
394                             ImeCommand.fromBundle(event.getArguments().getBundle("command"));
395                     return eventCommand.getId() == command.getId();
396                 }
397                 case "onCreate":
398                 case "onDestroy":
399                     return true;
400                 default:
401                     return false;
402             }
403         };
404         final ImeEventStream stream = session.openEventStream();
405         String lastEventName = null;
406         // Allowed pairs of (lastEventName, eventName):
407         //  - (null, "onCreate")
408         //  - ("onCreate", "onDestroy")
409         //  - ("onCreate", "onHandleCommand") -> then stop searching
410         //  - ("onDestroy", "onCreate")
411         while (true) {
412             final String eventName =
413                     stream.seekToFirst(matcher).map(ImeEvent::getEventName).orElse("");
414             final Pair<String, String> pair = Pair.create(lastEventName, eventName);
415             if (pair.equals(Pair.create("onCreate", "onHandleCommand"))) {
416                 break;  // Done!
417             }
418             if (pair.equals(Pair.create(null, "onCreate"))
419                     || pair.equals(Pair.create("onCreate", "onDestroy"))
420                     || pair.equals(Pair.create("onDestroy", "onCreate"))) {
421                 lastEventName = eventName;
422                 stream.skip(1);
423                 continue;
424             }
425             throw new AssertionError("IME might have crashed. lastEventName="
426                     + lastEventName + " eventName=" + eventName + "\n" + stream.dump());
427         }
428     }
429 
430     /**
431      * Waits until {@code MockIme} does not send {@code "onInputViewLayoutChanged"} event
432      * for a certain period of time ({@code stableThresholdTime} msec).
433      *
434      * <p>When this returns non-null {@link ImeLayoutInfo}, the stream position will be set to
435      * the next event of the returned layout event.  Otherwise this method does not change stream
436      * position.</p>
437      * @param stream {@link ImeEventStream} to be checked.
438      * @param stableThresholdTime threshold time to consider that {@link MockIme}'s layout is
439      *                            stable, in millisecond
440      * @return last {@link ImeLayoutInfo} if {@link MockIme} sent one or more
441      *         {@code "onInputViewLayoutChanged"} event.  Otherwise {@code null}
442      */
443     public static ImeLayoutInfo waitForInputViewLayoutStable(@NonNull ImeEventStream stream,
444             long stableThresholdTime) {
445         ImeLayoutInfo lastLayout = null;
446         final Predicate<ImeEvent> layoutFilter = event ->
447                 !event.isEnterEvent() && event.getEventName().equals("onInputViewLayoutChanged");
448         try {
449             long deadline = SystemClock.elapsedRealtime() + stableThresholdTime;
450             while (true) {
451                 if (deadline < SystemClock.elapsedRealtime()) {
452                     return lastLayout;
453                 }
454                 final Optional<ImeEvent> event = stream.seekToFirst(layoutFilter);
455                 if (event.isPresent()) {
456                     // Remember the last event and extend the deadline again.
457                     lastLayout = ImeLayoutInfo.readFromBundle(event.get().getArguments());
458                     deadline = SystemClock.elapsedRealtime() + stableThresholdTime;
459                     stream.skip(1);
460                 }
461                 Thread.sleep(TIME_SLICE);
462             }
463         } catch (InterruptedException e) {
464             throw new RuntimeException("notExpectEvent failed: " + stream.dump(), e);
465         }
466     }
467 
468     /**
469      * Clear all events with {@code eventName} in given {@code stream} and returns a forked
470      * {@link ImeEventStream} without events with {@code eventName}.
471      * <p>It is used to make sure previous events influence the test. </p>
472      *
473      * @param stream {@link ImeEventStream} to be cleared
474      * @param eventName The targeted cleared event name
475      * @return A forked {@link ImeEventStream} without event with {@code eventName}
476      */
477     public static ImeEventStream clearAllEvents(@NonNull ImeEventStream stream,
478             @NonNull String eventName) {
479         while (stream.seekToFirst(event -> eventName.equals(event.getEventName())).isPresent()) {
480             stream.skip(1);
481         }
482         return stream.copy();
483     }
484 
485     public static DescribedPredicate<ImeEvent> withDescription(String description, Predicate<ImeEvent> p) {
486         return new DescribedPredicate<>() {
487             @Override
488             public boolean test(ImeEvent ev) {
489                 return p.test(ev);
490             }
491 
492             public String describe() {
493                 return description;
494             }
495 
496             @Override
497             public String toString() {
498                 return describe();
499             }
500         };
501     }
502 
503     public interface DescribedPredicate<T> extends Predicate<ImeEvent> {
504         String describe();
505     }
506 
507     /**
508      *  A copy of {@link WindowLayoutInfo} class just for the purpose of testing with MockIME
509      *  test setup.
510      *  This is because only in this setup we will pass {@link WindowLayoutInfo} through
511      *  different processes.
512      */
513     public static class WindowLayoutInfoParcelable implements Parcelable {
514         private List<DisplayFeature> mDisplayFeatures = new ArrayList<DisplayFeature>();
515 
516         public WindowLayoutInfoParcelable(WindowLayoutInfo windowLayoutInfo) {
517             this.mDisplayFeatures = windowLayoutInfo.getDisplayFeatures();
518         }
519         public WindowLayoutInfoParcelable(Parcel in) {
520             while (in.dataAvail() > 0) {
521                 Rect bounds;
522                 int type = -1, state = -1;
523                 bounds = in.readParcelable(Rect.class.getClassLoader(), Rect.class);
524                 type = in.readInt();
525                 state = in.readInt();
526                 mDisplayFeatures.add(new FoldingFeature(bounds, type, state));
527             }
528         }
529 
530         @Override
531         public boolean equals(@Nullable Object o) {
532             if (this == o) {
533                 return true;
534             }
535             if (!(o instanceof WindowLayoutInfoParcelable)) {
536                 return false;
537             }
538 
539             List<androidx.window.extensions.layout.DisplayFeature> listA =
540                     this.getDisplayFeatures();
541             List<DisplayFeature> listB = ((WindowLayoutInfoParcelable) o).getDisplayFeatures();
542             if (listA.size() != listB.size()) return false;
543             for (int i = 0; i < listA.size(); ++i) {
544                 if (!listA.get(i).equals(listB.get(i))) {
545                     return false;
546                 }
547             }
548 
549             return true;
550         }
551 
552         @Override
553         public void writeToParcel(Parcel dest, int flags) {
554             // The actual implementation is FoldingFeature, DisplayFeature is an abstract class.
555             mDisplayFeatures.forEach(feature -> {
556                         dest.writeParcelable(feature.getBounds(), flags);
557                         dest.writeInt(((FoldingFeature) feature).getType());
558                         dest.writeInt(((FoldingFeature) feature).getState());
559                     }
560             );
561         }
562 
563         public List<DisplayFeature> getDisplayFeatures() {
564             return mDisplayFeatures;
565         }
566 
567         @Override
568         public int describeContents() {
569             return 0;
570         }
571 
572         public static final Parcelable.Creator<WindowLayoutInfoParcelable> CREATOR =
573                 new Parcelable.Creator<WindowLayoutInfoParcelable>() {
574 
575                     @Override
576                     public WindowLayoutInfoParcelable createFromParcel(Parcel in) {
577                         return new WindowLayoutInfoParcelable(in);
578                     }
579 
580                     @Override
581                     public WindowLayoutInfoParcelable[] newArray(int size) {
582                         return new WindowLayoutInfoParcelable[size];
583                     }
584                 };
585     }
586 }
587