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