1 /*
2  * Copyright (C) 2018 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.view.inputmethod.cts;
18 
19 import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
20 import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
21 import static android.inputmethodservice.InputMethodService.DISALLOW_INPUT_METHOD_INTERFACE_OVERRIDE;
22 import static android.server.wm.jetpack.extensions.util.ExtensionsUtil.assumeExtensionSupportedDevice;
23 import static android.view.WindowInsets.Type.ime;
24 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN;
25 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE;
26 import static android.view.inputmethod.cts.util.ConstantsUtils.DISAPPROVE_IME_PACKAGE_NAME;
27 import static android.view.inputmethod.cts.util.InputMethodVisibilityVerifier.expectImeInvisible;
28 import static android.view.inputmethod.cts.util.InputMethodVisibilityVerifier.expectImeVisible;
29 import static android.view.inputmethod.cts.util.TestUtils.getOnMainSync;
30 import static android.view.inputmethod.cts.util.TestUtils.runOnMainSync;
31 import static android.view.inputmethod.cts.util.TestUtils.waitOnMainUntil;
32 
33 import static com.android.cts.mockime.ImeEventStreamTestUtils.EventFilterMode.CHECK_EXIT_EVENT_ONLY;
34 import static com.android.cts.mockime.ImeEventStreamTestUtils.WindowLayoutInfoParcelable;
35 import static com.android.cts.mockime.ImeEventStreamTestUtils.editorMatcher;
36 import static com.android.cts.mockime.ImeEventStreamTestUtils.eventMatcher;
37 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectCommand;
38 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectEvent;
39 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectEventWithKeyValue;
40 import static com.android.cts.mockime.ImeEventStreamTestUtils.notExpectEvent;
41 import static com.android.cts.mockime.ImeEventStreamTestUtils.showSoftInputMatcher;
42 import static com.android.cts.mockime.ImeEventStreamTestUtils.verificationMatcher;
43 import static com.android.cts.mockime.ImeEventStreamTestUtils.withDescription;
44 
45 import static com.google.common.truth.Truth.assertThat;
46 import static com.google.common.truth.Truth.assertWithMessage;
47 import static com.google.common.truth.TruthJUnit.assume;
48 
49 import static org.junit.Assert.assertEquals;
50 import static org.junit.Assert.assertNotNull;
51 import static org.junit.Assert.assertTrue;
52 import static org.junit.Assert.fail;
53 import static org.junit.Assume.assumeTrue;
54 
55 import android.app.Activity;
56 import android.app.Instrumentation;
57 import android.content.Context;
58 import android.content.Intent;
59 import android.content.pm.PackageManager;
60 import android.graphics.Matrix;
61 import android.graphics.Rect;
62 import android.graphics.RectF;
63 import android.inputmethodservice.InputMethodService;
64 import android.os.Bundle;
65 import android.os.SystemClock;
66 import android.platform.test.annotations.AppModeSdkSandbox;
67 import android.server.wm.DisplayMetricsSession;
68 import android.support.test.uiautomator.UiObject2;
69 import android.text.TextUtils;
70 import android.view.Display;
71 import android.view.KeyCharacterMap;
72 import android.view.KeyEvent;
73 import android.view.View;
74 import android.view.inputmethod.CursorAnchorInfo;
75 import android.view.inputmethod.EditorBoundsInfo;
76 import android.view.inputmethod.EditorInfo;
77 import android.view.inputmethod.InputConnection;
78 import android.view.inputmethod.InputConnectionWrapper;
79 import android.view.inputmethod.InputMethodInfo;
80 import android.view.inputmethod.InputMethodManager;
81 import android.view.inputmethod.TextAppearanceInfo;
82 import android.view.inputmethod.cts.disapproveime.DisapproveInputMethodService;
83 import android.view.inputmethod.cts.util.EndToEndImeTestBase;
84 import android.view.inputmethod.cts.util.TestActivity;
85 import android.view.inputmethod.cts.util.TestActivity2;
86 import android.view.inputmethod.cts.util.TestUtils;
87 import android.view.inputmethod.cts.util.TestWebView;
88 import android.view.inputmethod.cts.util.UnlockScreenRule;
89 import android.webkit.WebView;
90 import android.widget.EditText;
91 import android.widget.LinearLayout;
92 
93 import androidx.annotation.NonNull;
94 import androidx.test.filters.FlakyTest;
95 import androidx.test.filters.MediumTest;
96 import androidx.test.platform.app.InstrumentationRegistry;
97 import androidx.test.rule.ServiceTestRule;
98 import androidx.test.runner.AndroidJUnit4;
99 import androidx.window.extensions.layout.DisplayFeature;
100 import androidx.window.extensions.layout.WindowLayoutInfo;
101 
102 import com.android.compatibility.common.util.ApiTest;
103 import com.android.compatibility.common.util.PollingCheck;
104 import com.android.compatibility.common.util.SystemUtil;
105 import com.android.cts.mockime.ImeCommand;
106 import com.android.cts.mockime.ImeEvent;
107 import com.android.cts.mockime.ImeEventStream;
108 import com.android.cts.mockime.ImeEventStreamTestUtils.DescribedPredicate;
109 import com.android.cts.mockime.ImeSettings;
110 import com.android.cts.mockime.MockImeSession;
111 
112 import org.junit.Assume;
113 import org.junit.Before;
114 import org.junit.Rule;
115 import org.junit.Test;
116 import org.junit.runner.RunWith;
117 
118 import java.io.IOException;
119 import java.util.ArrayList;
120 import java.util.List;
121 import java.util.concurrent.CountDownLatch;
122 import java.util.concurrent.TimeUnit;
123 import java.util.concurrent.TimeoutException;
124 import java.util.concurrent.atomic.AtomicInteger;
125 import java.util.concurrent.atomic.AtomicReference;
126 import java.util.function.Function;
127 
128 /**
129  * Tests for {@link InputMethodService} methods.
130  *
131  * Build/Install/Run:
132  * atest CtsInputMethodTestCases:InputMethodServiceTest
133  */
134 @MediumTest
135 @RunWith(AndroidJUnit4.class)
136 @AppModeSdkSandbox(reason = "Allow test in the SDK sandbox (does not prevent other modes).")
137 public class InputMethodServiceTest extends EndToEndImeTestBase {
138     private static final String TAG = "InputMethodServiceTest";
139     private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(20);
140     private static final long EXPECTED_TIMEOUT = TimeUnit.SECONDS.toMillis(2);
141     private static final long ACTIVITY_LAUNCH_INTERVAL = 500;  // msec
142 
143     private static final String OTHER_IME_ID = "com.android.cts.spellcheckingime/.SpellCheckingIme";
144 
145     private static final String ERASE_FONT_SCALE_CMD = "settings delete system font_scale";
146     // 1.2 is an arbitrary value.
147     private static final String PUT_FONT_SCALE_CMD = "settings put system font_scale 1.2";
148 
149     @Rule
150     public final UnlockScreenRule mUnlockScreenRule = new UnlockScreenRule();
151     @Rule
152     public final ServiceTestRule mServiceRule = new ServiceTestRule();
153 
154     private final String mMarker = getTestMarker();
155 
156     private Instrumentation mInstrumentation;
157 
backKeyDownMatcher(boolean expectedReturnValue)158     private static DescribedPredicate<ImeEvent> backKeyDownMatcher(boolean expectedReturnValue) {
159         return withDescription("onKeyDown(KEYCODE_BACK) = " + expectedReturnValue, event -> {
160             if (!TextUtils.equals("onKeyDown", event.getEventName())) {
161                 return false;
162             }
163             final int keyCode = event.getArguments().getInt("keyCode");
164             if (keyCode != KeyEvent.KEYCODE_BACK) {
165                 return false;
166             }
167             return event.getReturnBooleanValue() == expectedReturnValue;
168         });
169     }
170 
171     @Before
172     public void setup() {
173         mInstrumentation = InstrumentationRegistry.getInstrumentation();
174     }
175 
176     private TestActivity createTestActivity(int windowFlags) {
177         return TestActivity.startSync(activity -> createLayout(windowFlags, activity));
178     }
179 
180     private TestActivity createTestActivity(int windowFlags, int displayId) {
181         return new TestActivity.Starter().withDisplayId(displayId).startSync(
182                 activity -> createLayout(windowFlags, activity), TestActivity.class);
183     }
184 
185     private TestActivity createTestActivity2(int windowFlags) {
186         return new TestActivity.Starter().startSync(activity -> createLayout(windowFlags, activity),
187                 TestActivity2.class);
188     }
189 
190     private LinearLayout createLayout(final int windowFlags, final Activity activity) {
191         final LinearLayout layout = new LinearLayout(activity);
192         layout.setOrientation(LinearLayout.VERTICAL);
193 
194         final EditText editText = new EditText(activity);
195         editText.setText("Editable");
196         editText.setPrivateImeOptions(mMarker);
197         layout.addView(editText);
198         editText.requestFocus();
199 
200         activity.getWindow().setSoftInputMode(windowFlags);
201         return layout;
202     }
203 
204 
205     @Test
206     public void verifyLayoutInflaterContext() throws Exception {
207         try (MockImeSession imeSession = MockImeSession.create(
208                 InstrumentationRegistry.getInstrumentation().getContext(),
209                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
210                 new ImeSettings.Builder())) {
211             final ImeEventStream stream = imeSession.openEventStream();
212 
213             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
214             expectEvent(stream, editorMatcher("onStartInputView", mMarker), TIMEOUT);
215 
216             final ImeCommand command = imeSession.verifyLayoutInflaterContext();
217             assertTrue("InputMethodService.getLayoutInflater().getContext() must be equal to"
218                     + " InputMethodService.this",
219                     expectCommand(stream, command, TIMEOUT).getReturnBooleanValue());
220         }
221     }
222 
223     @Test
224     public void testSwitchInputMethod_verifiesEnabledState() throws Exception {
225         Assume.assumeFalse(isPreventImeStartup());
226         SystemUtil.runShellCommandOrThrow("ime disable " + OTHER_IME_ID);
227         try (MockImeSession imeSession = MockImeSession.create(
228                 InstrumentationRegistry.getInstrumentation().getContext(),
229                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
230                 new ImeSettings.Builder())) {
231             final ImeEventStream stream = imeSession.openEventStream();
232             expectEvent(stream, eventMatcher("onStartInput"), TIMEOUT);
233 
234             final ImeCommand cmd = imeSession.callSwitchInputMethod(OTHER_IME_ID);
235             final ImeEvent event = expectCommand(stream, cmd, TIMEOUT);
236             assertTrue("should be exception result, but wasn't" + event,
237                     event.isExceptionReturnValue());
238             // Should be IllegalStateException, but CompletableFuture converts to RuntimeException
239             assertTrue("should be RuntimeException, but wasn't: "
240                             + event.getReturnExceptionValue(),
241                     event.getReturnExceptionValue() instanceof RuntimeException);
242             assertTrue(
243                     "should contain 'not enabled' but didn't: " + event.getReturnExceptionValue(),
244                     event.getReturnExceptionValue().getMessage().contains("not enabled"));
245         }
246     }
247     @Test
248     public void testSwitchInputMethodWithSubtype_verifiesEnabledState() throws Exception {
249         Assume.assumeFalse(isPreventImeStartup());
250         SystemUtil.runShellCommand("ime disable " + OTHER_IME_ID);
251         try (MockImeSession imeSession = MockImeSession.create(
252                 InstrumentationRegistry.getInstrumentation().getContext(),
253                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
254                 new ImeSettings.Builder())) {
255             final ImeEventStream stream = imeSession.openEventStream();
256             expectEvent(stream, eventMatcher("onStartInput"), TIMEOUT);
257 
258             final ImeCommand cmd = imeSession.callSwitchInputMethod(OTHER_IME_ID, null);
259             final ImeEvent event = expectCommand(stream, cmd, TIMEOUT);
260             assertTrue("should be exception result, but wasn't" + event,
261                     event.isExceptionReturnValue());
262             // Should be IllegalStateException, but CompletableFuture converts to RuntimeException
263             assertTrue("should be RuntimeException, but wasn't: "
264                             + event.getReturnExceptionValue(),
265                     event.getReturnExceptionValue() instanceof RuntimeException);
266             assertTrue(
267                     "should contain 'not enabled' but didn't: " + event.getReturnExceptionValue(),
268                     event.getReturnExceptionValue().getMessage().contains("not enabled"));
269         }
270     }
271 
272     private void verifyImeConsumesBackButton(int backDisposition) throws Exception {
273         try (MockImeSession imeSession = MockImeSession.create(
274                 InstrumentationRegistry.getInstrumentation().getContext(),
275                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
276                 new ImeSettings.Builder())) {
277             final ImeEventStream stream = imeSession.openEventStream();
278 
279             final TestActivity testActivity = createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
280             expectEvent(stream, editorMatcher("onStartInputView", mMarker), TIMEOUT);
281 
282             final ImeCommand command = imeSession.callSetBackDisposition(backDisposition);
283             expectCommand(stream, command, TIMEOUT);
284 
285             testActivity.setIgnoreBackKey(true);
286             assertEquals(0,
287                     (long) getOnMainSync(() -> testActivity.getOnBackPressedCallCount()));
288             mInstrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK);
289 
290             expectEvent(stream, backKeyDownMatcher(true), CHECK_EXIT_EVENT_ONLY, TIMEOUT);
291 
292             // Make sure TestActivity#onBackPressed() is NOT called.
293             try {
294                 waitOnMainUntil(() -> testActivity.getOnBackPressedCallCount() > 0,
295                         EXPECTED_TIMEOUT);
296                 fail("Activity#onBackPressed() should not be called");
297             } catch (TimeoutException e) {
298                 // This is fine.  We actually expect timeout.
299             }
300         }
301     }
302 
303     @Test
304     public void testSetBackDispositionDefault() throws Exception {
305         verifyImeConsumesBackButton(InputMethodService.BACK_DISPOSITION_DEFAULT);
306     }
307 
308     @Test
309     public void testSetBackDispositionWillNotDismiss() throws Exception {
310         verifyImeConsumesBackButton(InputMethodService.BACK_DISPOSITION_WILL_NOT_DISMISS);
311     }
312 
313     @Test
314     public void testSetBackDispositionWillDismiss() throws Exception {
315         verifyImeConsumesBackButton(InputMethodService.BACK_DISPOSITION_WILL_DISMISS);
316     }
317 
318     @Test
319     public void testSetBackDispositionAdjustNothing() throws Exception {
320         verifyImeConsumesBackButton(InputMethodService.BACK_DISPOSITION_ADJUST_NOTHING);
321     }
322 
323     @Test
324     public void testRequestHideSelf() throws Exception {
325         try (MockImeSession imeSession = MockImeSession.create(
326                 InstrumentationRegistry.getInstrumentation().getContext(),
327                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
328                 new ImeSettings.Builder())) {
329             final ImeEventStream stream = imeSession.openEventStream();
330 
331             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
332             expectEvent(stream, editorMatcher("onStartInputView", mMarker), TIMEOUT);
333 
334             expectImeVisible(TIMEOUT);
335 
336             imeSession.callRequestHideSelf(0);
337             expectEvent(stream, eventMatcher("hideSoftInput"), TIMEOUT);
338             expectEvent(stream, eventMatcher("onFinishInputView"), TIMEOUT);
339             expectEventWithKeyValue(stream, "onWindowVisibilityChanged", "visible",
340                     View.GONE, TIMEOUT);
341 
342             expectImeInvisible(TIMEOUT);
343         }
344     }
345 
346     @Test
347     @FlakyTest(detail = "slow test")
348     public void testRequestShowSelf() throws Exception {
349         try (MockImeSession imeSession = MockImeSession.create(
350                 InstrumentationRegistry.getInstrumentation().getContext(),
351                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
352                 new ImeSettings.Builder())) {
353             final ImeEventStream stream = imeSession.openEventStream();
354 
355             createTestActivity(SOFT_INPUT_STATE_ALWAYS_HIDDEN);
356             notExpectEvent(
357                     stream, editorMatcher("onStartInputView", mMarker), TIMEOUT);
358 
359             expectImeInvisible(TIMEOUT);
360 
361             imeSession.callRequestShowSelf(0);
362             expectEvent(stream, eventMatcher("showSoftInput"), TIMEOUT);
363             expectEvent(stream, editorMatcher("onStartInputView", mMarker), TIMEOUT);
364             expectEventWithKeyValue(stream, "onWindowVisibilityChanged", "visible",
365                     View.VISIBLE, TIMEOUT);
366 
367             expectImeVisible(TIMEOUT);
368         }
369     }
370 
371     @FlakyTest(bugId = 210680326)
372     @Test
373     public void testHandlesConfigChanges() throws Exception {
374         try (MockImeSession imeSession = MockImeSession.create(
375                 InstrumentationRegistry.getInstrumentation().getContext(),
376                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
377                 new ImeSettings.Builder())) {
378             final ImeEventStream stream = imeSession.openEventStream();
379 
380             // Case 1: Activity handles configChanges="fontScale"
381             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
382             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
383             expectEvent(stream, eventMatcher("showSoftInput"), TIMEOUT);
384             // MockIme handles fontScale. Make sure changing fontScale doesn't restart IME.
385             enableFontScale();
386             expectImeVisible(TIMEOUT);
387             // Make sure IME was not restarted.
388             notExpectEvent(stream, eventMatcher("onCreate"),
389                     EXPECTED_TIMEOUT);
390             notExpectEvent(stream, showSoftInputMatcher(0),
391                     EXPECTED_TIMEOUT);
392 
393             eraseFontScale();
394 
395             // Case 2: Activity *doesn't* handle configChanges="fontScale" and restarts.
396             createTestActivity2(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
397             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
398             // MockIme handles fontScale. Make sure changing fontScale doesn't restart IME.
399             enableFontScale();
400             expectImeVisible(TIMEOUT);
401             // Make sure IME was not restarted.
402             notExpectEvent(stream, eventMatcher("onCreate"),
403                     EXPECTED_TIMEOUT);
404         } finally {
405             eraseFontScale();
406         }
407     }
408 
409     /**
410      * Font scale is a global configuration.
411      * This function will apply font scale changes.
412      */
413     private void enableFontScale() {
414         try {
415             final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
416             SystemUtil.runShellCommand(instrumentation, PUT_FONT_SCALE_CMD);
417             instrumentation.waitForIdleSync();
418         } catch (IOException io) {
419             fail("Couldn't apply font scale.");
420         }
421     }
422 
423     /**
424      * Font scale is a global configuration.
425      * This function will apply font scale changes.
426      */
427     private void eraseFontScale() {
428         try {
429             final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
430             SystemUtil.runShellCommand(instrumentation, ERASE_FONT_SCALE_CMD);
431             instrumentation.waitForIdleSync();
432         } catch (IOException io) {
433             fail("Couldn't apply font scale.");
434         }
435     }
436 
437     private static void assertSynthesizedSoftwareKeyEvent(KeyEvent keyEvent, int expectedAction,
438             int expectedKeyCode, long expectedEventTimeBefore, long expectedEventTimeAfter) {
439         if (keyEvent.getEventTime() < expectedEventTimeBefore
440                 || expectedEventTimeAfter < keyEvent.getEventTime()) {
441             fail(String.format("EventTime must be within [%d, %d],"
442                             + " which was %d", expectedEventTimeBefore, expectedEventTimeAfter,
443                     keyEvent.getEventTime()));
444         }
445         assertEquals(expectedAction, keyEvent.getAction());
446         assertEquals(expectedKeyCode, keyEvent.getKeyCode());
447         assertEquals(KeyCharacterMap.VIRTUAL_KEYBOARD, keyEvent.getDeviceId());
448         assertEquals(0, keyEvent.getScanCode());
449         assertEquals(0, keyEvent.getRepeatCount());
450         assertEquals(0, keyEvent.getRepeatCount());
451         final int mustHaveFlags = KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_KEEP_TOUCH_MODE;
452         final int mustNotHaveFlags = KeyEvent.FLAG_FROM_SYSTEM;
453         if ((keyEvent.getFlags() & mustHaveFlags) == 0
454                 || (keyEvent.getFlags() & mustNotHaveFlags) != 0) {
455             fail(String.format("Flags must have FLAG_SOFT_KEYBOARD|"
456                     + "FLAG_KEEP_TOUCH_MODE and must not have FLAG_FROM_SYSTEM, "
457                     + "which was 0x%08X", keyEvent.getFlags()));
458         }
459     }
460 
461     /**
462      * Test compatibility requirements of {@link InputMethodService#sendDownUpKeyEvents(int)}.
463      */
464     @Test
465     public void testSendDownUpKeyEvents() throws Exception {
466         try (MockImeSession imeSession = MockImeSession.create(
467                 InstrumentationRegistry.getInstrumentation().getContext(),
468                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
469                 new ImeSettings.Builder())) {
470             final ImeEventStream stream = imeSession.openEventStream();
471 
472             final AtomicReference<ArrayList<KeyEvent>> keyEventsRef = new AtomicReference<>();
473             final String marker = "testSendDownUpKeyEvents/" + SystemClock.elapsedRealtimeNanos();
474 
475             TestActivity.startSync(activity -> {
476                 final LinearLayout layout = new LinearLayout(activity);
477                 layout.setOrientation(LinearLayout.VERTICAL);
478 
479                 final ArrayList<KeyEvent> keyEvents = new ArrayList<>();
480                 keyEventsRef.set(keyEvents);
481                 final EditText editText = new EditText(activity) {
482                     @Override
483                     public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
484                         return new InputConnectionWrapper(
485                                 super.onCreateInputConnection(editorInfo), false) {
486                             /**
487                              * {@inheritDoc}
488                              */
489                             @Override
490                             public boolean sendKeyEvent(KeyEvent event) {
491                                 keyEvents.add(event);
492                                 return super.sendKeyEvent(event);
493                             }
494                         };
495                     }
496                 };
497                 editText.setPrivateImeOptions(marker);
498                 layout.addView(editText);
499                 editText.requestFocus();
500                 return layout;
501             });
502 
503             // Wait until "onStartInput" gets called for the EditText.
504             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
505 
506             // Make sure that InputConnection#sendKeyEvent() has never been called yet.
507             assertTrue(TestUtils.getOnMainSync(
508                     () -> new ArrayList<>(keyEventsRef.get())).isEmpty());
509 
510             final int expectedKeyCode = KeyEvent.KEYCODE_0;
511             final long uptimeStart = SystemClock.uptimeMillis();
512             expectCommand(stream, imeSession.callSendDownUpKeyEvents(expectedKeyCode), TIMEOUT);
513             final long uptimeEnd = SystemClock.uptimeMillis();
514 
515             final ArrayList<KeyEvent> keyEvents = TestUtils.getOnMainSync(
516                     () -> new ArrayList<>(keyEventsRef.get()));
517 
518             // Check KeyEvent objects.
519             assertNotNull(keyEvents);
520             assertEquals(2, keyEvents.size());
521             assertSynthesizedSoftwareKeyEvent(keyEvents.get(0), KeyEvent.ACTION_DOWN,
522                     expectedKeyCode, uptimeStart, uptimeEnd);
523             assertSynthesizedSoftwareKeyEvent(keyEvents.get(1), KeyEvent.ACTION_UP,
524                     expectedKeyCode, uptimeStart, uptimeEnd);
525             final Bundle arguments = expectEvent(stream,
526                     eventMatcher("onUpdateSelection"),
527                     TIMEOUT).getArguments();
528             expectOnUpdateSelectionArguments(arguments, 0, 0, 1, 1, -1, -1);
529         }
530     }
531 
532     /**
533      * Ensure that {@link InputConnection#requestCursorUpdates(int)} works for the built-in
534      * {@link EditText} and {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)}
535      * will be called back.
536      */
537     @Test
538     public void testOnUpdateCursorAnchorInfo() throws Exception {
539         try (MockImeSession imeSession = MockImeSession.create(
540                 InstrumentationRegistry.getInstrumentation().getContext(),
541                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
542                 new ImeSettings.Builder())) {
543             final String marker =
544                     "testOnUpdateCursorAnchorInfo()/" + SystemClock.elapsedRealtimeNanos();
545 
546             final AtomicReference<EditText> editTextRef = new AtomicReference<>();
547             final AtomicInteger requestCursorUpdatesCallCount = new AtomicInteger();
548             final AtomicInteger requestCursorUpdatesWithFilterCallCount = new AtomicInteger();
549             TestActivity.startSync(activity -> {
550                 final LinearLayout layout = new LinearLayout(activity);
551                 layout.setOrientation(LinearLayout.VERTICAL);
552 
553                 final EditText editText = new EditText(activity) {
554                     @Override
555                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
556                         final InputConnection original = super.onCreateInputConnection(outAttrs);
557                         return new InputConnectionWrapper(original, false) {
558                             @Override
559                             public boolean requestCursorUpdates(int cursorUpdateMode) {
560                                 if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE)
561                                         != 0) {
562                                     requestCursorUpdatesCallCount.incrementAndGet();
563                                     return true;
564                                 }
565                                 return false;
566                             }
567 
568                             @Override
569                             public boolean requestCursorUpdates(
570                                     int cursorUpdateMode, int cursorUpdateFilter) {
571                                 requestCursorUpdatesWithFilterCallCount.incrementAndGet();
572                                 return requestCursorUpdates(cursorUpdateMode | cursorUpdateFilter);
573                             }
574                         };
575                     }
576                 };
577                 editTextRef.set(editText);
578                 editText.setPrivateImeOptions(marker);
579                 layout.addView(editText);
580                 editText.requestFocus();
581                 return layout;
582             });
583             final EditText editText = editTextRef.get();
584 
585             final ImeEventStream stream = imeSession.openEventStream();
586             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
587 
588             // Make sure that InputConnection#requestCursorUpdates() returns true.
589             assertTrue(expectCommand(stream,
590                     imeSession.callRequestCursorUpdates(InputConnection.CURSOR_UPDATE_IMMEDIATE),
591                     TIMEOUT).getReturnBooleanValue());
592 
593             // Also make sure that requestCursorUpdates() actually gets called only once.
594             assertEquals(1, requestCursorUpdatesCallCount.get());
595 
596             final CursorAnchorInfo originalCursorAnchorInfo = new CursorAnchorInfo.Builder()
597                     .setMatrix(new Matrix())
598                     .setInsertionMarkerLocation(3.0f, 4.0f, 5.0f, 6.0f, 0)
599                     .setSelectionRange(7, 8)
600                     .build();
601 
602             runOnMainSync(() -> editText.getContext().getSystemService(InputMethodManager.class)
603                     .updateCursorAnchorInfo(editText, originalCursorAnchorInfo));
604 
605             final CursorAnchorInfo receivedCursorAnchorInfo = expectEvent(stream,
606                     eventMatcher("onUpdateCursorAnchorInfo"),
607                     TIMEOUT).getArguments().getParcelable("cursorAnchorInfo");
608             assertNotNull(receivedCursorAnchorInfo);
609             assertEquals(receivedCursorAnchorInfo, originalCursorAnchorInfo);
610 
611             requestCursorUpdatesCallCount.set(0);
612             // Request Cursor updates with Filter
613             // Make sure that InputConnection#requestCursorUpdates() returns true with data filter.
614             assertTrue(expectCommand(stream,
615                     imeSession.callRequestCursorUpdates(
616                             InputConnection.CURSOR_UPDATE_IMMEDIATE
617                             | InputConnection.CURSOR_UPDATE_FILTER_EDITOR_BOUNDS
618                             | InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS
619                             | InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER
620                             | InputConnection.CURSOR_UPDATE_FILTER_VISIBLE_LINE_BOUNDS
621                             | InputConnection.CURSOR_UPDATE_FILTER_TEXT_APPEARANCE),
622                     TIMEOUT).getReturnBooleanValue());
623 
624             // Also make sure that requestCursorUpdates() actually gets called only once.
625             assertEquals(1, requestCursorUpdatesCallCount.get());
626 
627             EditorBoundsInfo.Builder builder = new EditorBoundsInfo.Builder();
628             builder.setEditorBounds(new RectF(0f, 1f, 2f, 3f));
629             final CursorAnchorInfo originalCursorAnchorInfo1 = new CursorAnchorInfo.Builder()
630                     .setMatrix(new Matrix())
631                     .setEditorBoundsInfo(builder.build())
632                     .addVisibleLineBounds(1f, 2f, 3f, 5f)
633                     .setTextAppearanceInfo(new TextAppearanceInfo.Builder().build())
634                     .build();
635 
636             runOnMainSync(() -> editText.getContext().getSystemService(InputMethodManager.class)
637                     .updateCursorAnchorInfo(editText, originalCursorAnchorInfo1));
638 
639             final CursorAnchorInfo receivedCursorAnchorInfo1 = expectEvent(stream,
640                     eventMatcher("onUpdateCursorAnchorInfo"),
641                     TIMEOUT).getArguments().getParcelable("cursorAnchorInfo");
642             assertNotNull(receivedCursorAnchorInfo1);
643             assertEquals(receivedCursorAnchorInfo1, originalCursorAnchorInfo1);
644 
645             requestCursorUpdatesCallCount.set(0);
646             requestCursorUpdatesWithFilterCallCount.set(0);
647             // Request Cursor updates with Mode and Filter
648             // Make sure that InputConnection#requestCursorUpdates() returns true with mode and
649             // data filter.
650             builder = new EditorBoundsInfo.Builder();
651             builder.setEditorBounds(new RectF(1f, 1f, 2f, 3f));
652             final CursorAnchorInfo originalCursorAnchorInfo2 = new CursorAnchorInfo.Builder()
653                     .setMatrix(new Matrix())
654                     .setEditorBoundsInfo(builder.build())
655                     .addVisibleLineBounds(1f, 2f, 3f, 4f)
656                     .setTextAppearanceInfo(new TextAppearanceInfo.Builder().build())
657                     .build();
658             assertTrue(expectCommand(stream,
659                     imeSession.callRequestCursorUpdates(
660                             InputConnection.CURSOR_UPDATE_IMMEDIATE,
661                                     InputConnection.CURSOR_UPDATE_FILTER_EDITOR_BOUNDS
662                                     | InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS
663                                     | InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER
664                                     | InputConnection.CURSOR_UPDATE_FILTER_VISIBLE_LINE_BOUNDS
665                                     | InputConnection.CURSOR_UPDATE_FILTER_TEXT_APPEARANCE),
666                     TIMEOUT).getReturnBooleanValue());
667 
668             // Make sure that requestCursorUpdates() actually gets called only once.
669             assertEquals(1, requestCursorUpdatesCallCount.get());
670             assertEquals(1, requestCursorUpdatesWithFilterCallCount.get());
671             runOnMainSync(() -> editText.getContext().getSystemService(InputMethodManager.class)
672                     .updateCursorAnchorInfo(editText, originalCursorAnchorInfo2));
673 
674             final CursorAnchorInfo receivedCursorAnchorInfo2 = expectEvent(stream,
675                     eventMatcher("onUpdateCursorAnchorInfo"),
676                     TIMEOUT).getArguments().getParcelable("cursorAnchorInfo");
677             assertNotNull(receivedCursorAnchorInfo2);
678             assertEquals(receivedCursorAnchorInfo2, originalCursorAnchorInfo2);
679         }
680     }
681 
682     /** Test that no exception is thrown when {@link InputMethodService#getDisplay()} is called */
683     @Test
684     public void testGetDisplay() throws Exception {
685         try (MockImeSession imeSession = MockImeSession.create(
686                 mInstrumentation.getContext(), mInstrumentation.getUiAutomation(),
687                 new ImeSettings.Builder().setVerifyUiContextApisInOnCreate(true))) {
688             ensureImeRunning();
689             final ImeEventStream stream = imeSession.openEventStream();
690 
691             // Verify if getDisplay doesn't throw exception before InputMethodService's
692             // initialization.
693             assertTrue(expectEvent(stream, verificationMatcher("getDisplay"),
694                     CHECK_EXIT_EVENT_ONLY, TIMEOUT).getReturnBooleanValue());
695             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
696 
697             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
698             // Verify if getDisplay doesn't throw exception
699             assertTrue(expectCommand(stream, imeSession.callVerifyGetDisplay(), TIMEOUT)
700                     .getReturnBooleanValue());
701         }
702     }
703 
704     /** Test the cursor position of {@link EditText} is correct after typing on another activity. */
705     @Test
706     public void testCursorAfterLaunchAnotherActivity() throws Exception {
707         final AtomicReference<EditText> firstEditTextRef = new AtomicReference<>();
708         final int newCursorOffset = 5;
709         final String initialText = "Initial";
710         final String firstCommitMsg = "First";
711         final String secondCommitMsg = "Second";
712 
713         try (MockImeSession imeSession = MockImeSession.create(
714                 InstrumentationRegistry.getInstrumentation().getContext(),
715                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
716                 new ImeSettings.Builder())) {
717             final String marker =
718                     "testCursorAfterLaunchAnotherActivity()/" + SystemClock.elapsedRealtimeNanos();
719 
720             // Launch first test activity
721             TestActivity.startSync(activity -> {
722                 final LinearLayout layout = new LinearLayout(activity);
723                 layout.setOrientation(LinearLayout.VERTICAL);
724 
725                 final EditText editText = new EditText(activity);
726                 editText.setPrivateImeOptions(marker);
727                 editText.setSingleLine(false);
728                 firstEditTextRef.set(editText);
729                 editText.setText(initialText);
730                 layout.addView(editText);
731                 editText.requestFocus();
732                 return layout;
733             });
734 
735             final EditText firstEditText = firstEditTextRef.get();
736             final ImeEventStream stream = imeSession.openEventStream();
737 
738             // Verify onStartInput when first activity launch
739             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
740 
741             final ImeCommand commit = imeSession.callCommitText(firstCommitMsg, 1);
742             expectCommand(stream, commit, TIMEOUT);
743             TestUtils.waitOnMainUntil(
744                     () -> TextUtils.equals(
745                             firstEditText.getText(), initialText + firstCommitMsg), TIMEOUT);
746 
747             // Get current position
748             int originalSelectionStart = firstEditText.getSelectionStart();
749             int originalSelectionEnd = firstEditText.getSelectionEnd();
750 
751             assertEquals(initialText.length() + firstCommitMsg.length(), originalSelectionStart);
752             assertEquals(initialText.length() + firstCommitMsg.length(), originalSelectionEnd);
753 
754             // Launch second test activity
755             final Intent intent = new Intent()
756                     .setAction(Intent.ACTION_MAIN)
757                     .setClass(InstrumentationRegistry.getInstrumentation().getContext(),
758                             TestActivity.class)
759                     .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
760             TestActivity secondActivity = (TestActivity) InstrumentationRegistry
761                     .getInstrumentation().startActivitySync(intent);
762 
763             // Verify onStartInput when second activity launch
764             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
765 
766             // Commit some messages on second activity
767             final ImeCommand secondCommit = imeSession.callCommitText(secondCommitMsg, 1);
768             expectCommand(stream, secondCommit, TIMEOUT);
769 
770             // Back to first activity
771             runOnMainSync(secondActivity::onBackPressed);
772 
773             // Make sure TestActivity#onBackPressed() is called.
774             TestUtils.waitOnMainUntil(() -> secondActivity.getOnBackPressedCallCount() > 0,
775                     TIMEOUT, "Activity#onBackPressed() should be called");
776 
777             TestUtils.runOnMainSync(firstEditText::requestFocus);
778 
779             // Verify onStartInput when first activity launch
780             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
781 
782             // Update cursor to a new position
783             int newCursorPosition = originalSelectionStart - newCursorOffset;
784             final ImeCommand setSelection =
785                     imeSession.callSetSelection(newCursorPosition, newCursorPosition);
786             expectCommand(stream, setSelection, TIMEOUT);
787 
788             // Commit to first activity again
789             final ImeCommand commitFirstAgain = imeSession.callCommitText(firstCommitMsg, 1);
790             expectCommand(stream, commitFirstAgain, TIMEOUT);
791             TestUtils.waitOnMainUntil(
792                     () -> TextUtils.equals(firstEditText.getText(), "InitialFirstFirst"), TIMEOUT);
793 
794             // get new position
795             int newSelectionStart = firstEditText.getSelectionStart();
796             int newSelectionEnd = firstEditText.getSelectionEnd();
797 
798             assertEquals(newSelectionStart, newCursorPosition + firstCommitMsg.length());
799             assertEquals(newSelectionEnd, newCursorPosition + firstCommitMsg.length());
800         }
801     }
802 
803     @Test
804     public void testBatchEdit_commitAndSetComposingRegion_textView() throws Exception {
805         getCommitAndSetComposingRegionTest(TIMEOUT,
806                 "testBatchEdit_commitAndSetComposingRegion_textView/")
807                 .setTestTextView(true)
808                 .runTest();
809     }
810 
811     @FlakyTest(bugId = 300314534)
812     @Test
813     public void testBatchEdit_commitAndSetComposingRegion_webView() throws Exception {
814         assumeTrue(hasFeatureWebView());
815 
816         getCommitAndSetComposingRegionTest(TIMEOUT,
817                 "testBatchEdit_commitAndSetComposingRegion_webView/")
818                 .setTestTextView(false)
819                 .runTest();
820     }
821 
822     @Test
823     public void testBatchEdit_commitSpaceThenSetComposingRegion_textView() throws Exception {
824         getCommitSpaceAndSetComposingRegionTest(TIMEOUT,
825                 "testBatchEdit_commitSpaceThenSetComposingRegion_textView/")
826                 .setTestTextView(true)
827                 .runTest();
828     }
829 
830     @Test
831     @FlakyTest(bugId = 294840051)
832     public void testBatchEdit_commitSpaceThenSetComposingRegion_webView() throws Exception {
833         assumeTrue(hasFeatureWebView());
834 
835         getCommitSpaceAndSetComposingRegionTest(TIMEOUT,
836                 "testBatchEdit_commitSpaceThenSetComposingRegion_webView/")
837                 .setTestTextView(false)
838                 .runTest();
839     }
840 
841     @Test
842     public void testBatchEdit_getCommitSpaceAndSetComposingRegionTestInSelectionTest_textView()
843             throws Exception {
844         getCommitSpaceAndSetComposingRegionInSelectionTest(TIMEOUT,
845                 "testBatchEdit_getCommitSpaceAndSetComposingRegionTestInSelectionTest_textView/")
846                 .setTestTextView(true)
847                 .runTest();
848     }
849 
850     @FlakyTest(bugId = 333155542)
851     @Test
852     public void testBatchEdit_getCommitSpaceAndSetComposingRegionTestInSelectionTest_webView()
853             throws Exception {
854         assumeTrue(hasFeatureWebView());
855 
856         getCommitSpaceAndSetComposingRegionInSelectionTest(TIMEOUT,
857                 "testBatchEdit_getCommitSpaceAndSetComposingRegionTestInSelectionTest_webView/")
858                 .setTestTextView(false)
859                 .runTest();
860     }
861 
862     private boolean hasFeatureWebView() {
863         final PackageManager pm =
864                 InstrumentationRegistry.getInstrumentation().getContext().getPackageManager();
865         return pm.hasSystemFeature(PackageManager.FEATURE_WEBVIEW);
866     }
867 
868     @Test
869     public void testImeVisibleAfterRotation() throws Exception {
870         try (MockImeSession imeSession = MockImeSession.create(
871                 InstrumentationRegistry.getInstrumentation().getContext(),
872                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
873                 new ImeSettings.Builder())) {
874             final ImeEventStream stream = imeSession.openEventStream();
875 
876             final Activity activity = createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
877             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
878             final int initialOrientation = activity.getRequestedOrientation();
879             try {
880                 activity.setRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE);
881                 mInstrumentation.waitForIdleSync();
882                 expectImeVisible(TIMEOUT);
883 
884                 activity.setRequestedOrientation(SCREEN_ORIENTATION_PORTRAIT);
885                 mInstrumentation.waitForIdleSync();
886                 expectImeVisible(TIMEOUT);
887             } finally {
888                 if (initialOrientation != SCREEN_ORIENTATION_PORTRAIT) {
889                     activity.setRequestedOrientation(initialOrientation);
890                 }
891             }
892         }
893     }
894 
895     /**
896      * Starts a {@link MockImeSession} and verifies MockIme receives {@link WindowLayoutInfo}
897      * updates. Trigger Configuration changes by modifying the DisplaySession where MockIME window
898      * is located, then verify Bounds from MockIME window and {@link DisplayFeature} from
899      * WindowLayoutInfo updates observe the same changes to the hinge location.
900      * Here we use {@link WindowLayoutInfoParcelable} to pass {@link WindowLayoutInfo} values
901      * between this test process and the MockIME process.
902      */
903     @Test
904     @ApiTest(apis = {
905             "androidx.window.extensions.layout.WindowLayoutComponent#addWindowLayoutInfoListener"})
906     public void testImeListensToWindowLayoutInfo() throws Exception {
907         assumeExtensionSupportedDevice();
908 
909         final double resizeRatio = 0.8;
910         try (MockImeSession imeSession = MockImeSession.create(
911                 InstrumentationRegistry.getInstrumentation().getContext(),
912                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
913                 new ImeSettings.Builder().setWindowLayoutInfoCallbackEnabled(true))) {
914 
915             final ImeEventStream stream = imeSession.openEventStream();
916             TestActivity activity = createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
917             assertThat(expectEvent(stream, verificationMatcher("windowLayoutComponentLoaded"),
918                     CHECK_EXIT_EVENT_ONLY, TIMEOUT).getReturnBooleanValue()).isTrue();
919             final Display display = activity.getDisplay();
920             assertThat(display).isNotNull();
921 
922             final int displayId = display.getDisplayId();
923             try (DisplayMetricsSession displaySession = new DisplayMetricsSession(displayId)) {
924                 // MockIME has registered addWindowLayoutInfo, it should be emitting the
925                 // current location of hinge now.
926                 WindowLayoutInfoParcelable windowLayoutInit = verifyReceivedWindowLayout(stream);
927                 assertThat(windowLayoutInit).isNotNull();
928                 final List<DisplayFeature> featuresInit = windowLayoutInit.getDisplayFeatures();
929                 assertThat(featuresInit).isNotNull();
930 
931                 // Skip the test if the device doesn't support hinges.
932                 assume().that(featuresInit).isNotEmpty();
933 
934                 final Rect windowBoundsInit = featuresInit.get(0).getBounds();
935                 expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
936                 expectEvent(stream, eventMatcher("showSoftInput"), TIMEOUT);
937 
938                 // After IME is shown, get the bounds of IME.
939                 final Rect imeBoundsInit = expectCommand(stream,
940                         imeSession.callGetCurrentWindowMetricsBounds(), TIMEOUT)
941                         .getReturnParcelableValue();
942 
943                 // Contain first part of the test in a try-block so that the display session
944                 // could be restored for the remaining testsuite even if something fails.
945                 try {
946                     // Shrink the entire display 20% smaller.
947                     displaySession.changeDisplayMetrics(resizeRatio /* sizeRatio */,
948                             1.0 /* densityRatio */);
949 
950                     // onConfigurationChanged on WM side triggers a new calculation for
951                     // hinge location.
952                     final WindowLayoutInfoParcelable windowLayoutResized =
953                             verifyReceivedWindowLayout(stream);
954 
955                     // Expect to receive same number of display features in WindowLayoutInfo.
956                     final List<DisplayFeature> featuresResized =
957                             windowLayoutResized.getDisplayFeatures();
958                     assertThat(featuresResized).hasSize(featuresInit.size());
959 
960                     final Rect windowBoundsResized = featuresResized.get(0).getBounds();
961                     final Rect imeBoundsResized = expectCommand(stream,
962                             imeSession.callGetCurrentWindowMetricsBounds(), TIMEOUT)
963                             .getReturnParcelableValue();
964 
965                     final StringBuilder errorMessage = new StringBuilder();
966                     final Function<Function<Rect, Integer>, Boolean> inSameRatio = getSize -> {
967                         final boolean windowResizedCorrectly = isResizedWithRatio(getSize,
968                                 windowBoundsInit, windowBoundsResized, resizeRatio, errorMessage);
969                         final boolean imeResizedCorrectly = isResizedWithRatio(getSize,
970                                 imeBoundsInit, imeBoundsResized, resizeRatio, errorMessage);
971                         return windowResizedCorrectly && imeResizedCorrectly;
972                     };
973                     final boolean widthsChangedInSameRatio = inSameRatio.apply(Rect::width);
974                     final boolean heightsChangedInSameRatio = inSameRatio.apply(Rect::height);
975 
976                     // Expect the hinge dimension to shrink in exactly one direction, the actual
977                     // dimension depends on device implementation. Observe hinge dimensions from
978                     // IME configuration bounds and from WindowLayoutInfo.
979                     assertWithMessage(
980                             "Expected either width or height to change proportionally.\n"
981                                     + " widthsChangedInSameRatio: "
982                                     + widthsChangedInSameRatio + "\n"
983                                     + " heightsChangedInSameRatio: "
984                                     + heightsChangedInSameRatio + "\n"
985                                     + " Resize ratio: " + String.format("%.1f", resizeRatio) + "\n"
986                                     + " Initial window bounds: " + windowBoundsInit + "\n"
987                                     + " Resized window bounds: " + windowBoundsResized + "\n"
988                                     + " Initial IME bounds: " + imeBoundsInit + "\n"
989                                     + " Resized IME bounds: " + imeBoundsResized + "\n"
990                                     + " Details:" + errorMessage)
991                             .that(widthsChangedInSameRatio || heightsChangedInSameRatio)
992                             .isTrue();
993                 } finally {
994                     // Restore Display to original size.
995                     displaySession.restoreDisplayMetrics();
996                 }
997 
998                 final WindowLayoutInfoParcelable restored = verifyReceivedWindowLayout(stream);
999                 final List<DisplayFeature> features = restored.getDisplayFeatures();
1000                 assertThat(features).isNotEmpty();
1001                 assertThat(features.get(0).getBounds()).isEqualTo(windowBoundsInit);
1002 
1003                 final Rect imeBoundsRestored = expectCommand(stream,
1004                         imeSession.callGetCurrentWindowMetricsBounds(), TIMEOUT)
1005                         .getReturnParcelableValue();
1006                 assertThat(imeBoundsRestored).isEqualTo(imeBoundsInit);
1007             }
1008         }
1009     }
1010 
1011     /** Verify if {@link InputMethodService#isUiContext()} returns {@code true}. */
1012     @Test
1013     public void testIsUiContext() throws Exception {
1014         try (MockImeSession imeSession = MockImeSession.create(
1015                 mInstrumentation.getContext(), mInstrumentation.getUiAutomation(),
1016                 new ImeSettings.Builder().setVerifyUiContextApisInOnCreate(true))) {
1017             ensureImeRunning();
1018             final ImeEventStream stream = imeSession.openEventStream();
1019 
1020             // Verify if InputMethodService#isUiContext returns true in #onCreate
1021             assertTrue(expectEvent(stream, verificationMatcher("isUiContext"),
1022                     CHECK_EXIT_EVENT_ONLY, TIMEOUT).getReturnBooleanValue());
1023             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1024 
1025             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
1026             // Verify if InputMethodService#isUiContext returns true
1027             assertTrue(expectCommand(stream, imeSession.callVerifyIsUiContext(), TIMEOUT)
1028                     .getReturnBooleanValue());
1029         }
1030     }
1031 
1032     @Test
1033     public void testNoConfigurationChangedOnStartInput() throws Exception {
1034         try (MockImeSession imeSession = MockImeSession.create(
1035                 mInstrumentation.getContext(), mInstrumentation.getUiAutomation(),
1036                 new ImeSettings.Builder())) {
1037             final ImeEventStream stream = imeSession.openEventStream();
1038 
1039             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1040 
1041             final ImeEventStream forkedStream = stream.copy();
1042             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
1043             // Verify if InputMethodService#isUiContext returns true
1044             notExpectEvent(forkedStream, eventMatcher("onConfigurationChanged"), EXPECTED_TIMEOUT);
1045         }
1046     }
1047 
1048     @Test
1049     public void testShowSoftInput_whenAllImesDisabled() {
1050         final InputMethodManager inputManager =
1051                 mInstrumentation.getTargetContext().getSystemService(InputMethodManager.class);
1052         assertNotNull(inputManager);
1053         final List<InputMethodInfo> enabledImes = inputManager.getEnabledInputMethodList();
1054 
1055         try {
1056             // disable all IMEs
1057             for (InputMethodInfo ime : enabledImes) {
1058                 SystemUtil.runShellCommand("ime disable " + ime.getId());
1059             }
1060 
1061             // start a test activity and expect it not to crash
1062             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1063         } finally {
1064             // restore all previous IMEs
1065             SystemUtil.runShellCommand("ime reset");
1066         }
1067     }
1068 
1069     @Test
1070     public void testImeOverrideSessionInterface_throwLinkageError() {
1071         SystemUtil.runCommandAndPrintOnLogcat(TAG, "am compat enable "
1072                 + DISALLOW_INPUT_METHOD_INTERFACE_OVERRIDE + " " + DISAPPROVE_IME_PACKAGE_NAME);
1073 
1074         final Context context = mInstrumentation.getContext();
1075         final CountDownLatch serviceCreateLatch = new CountDownLatch(1);
1076         final DisapproveInputMethodService.DisapproveImeCallback disapproveImeCallback =
1077                 hasLinkageError -> {
1078                     if (!hasLinkageError) {
1079                         fail("Should throw a LinkageError.");
1080                     }
1081                     serviceCreateLatch.countDown();
1082                 };
1083 
1084         try {
1085             DisapproveInputMethodService.setCallback(disapproveImeCallback);
1086             mServiceRule.bindService(new Intent(context, DisapproveInputMethodService.class));
1087         } catch (TimeoutException e) {
1088             fail("DisapproveInputMethodService binding timeout.");
1089         }
1090 
1091         boolean result = false;
1092         try {
1093             result = serviceCreateLatch.await(EXPECTED_TIMEOUT, TimeUnit.MILLISECONDS);
1094             if (!result) {
1095                 fail("Timeout before receiving the result.");
1096             }
1097         } catch (InterruptedException ignored) {
1098         } finally {
1099             SystemUtil.runCommandAndPrintOnLogcat(TAG, "am compat reset "
1100                     + DISALLOW_INPUT_METHOD_INTERFACE_OVERRIDE + " " + DISAPPROVE_IME_PACKAGE_NAME);
1101         }
1102     }
1103 
1104     /**
1105      * Verifies that requesting to hide the IME caption bar does not lead
1106      * to any undesired behaviour (e.g. crashing, hiding the IME when it was visible, etc.).
1107      */
1108     @Test
1109     public void testRequestHideImeCaptionBar() throws Exception {
1110         try (MockImeSession imeSession = MockImeSession.create(
1111                 InstrumentationRegistry.getInstrumentation().getContext(),
1112                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
1113                 new ImeSettings.Builder())) {
1114             final ImeEventStream stream = imeSession.openEventStream();
1115 
1116             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1117             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
1118             expectImeVisible(TIMEOUT);
1119 
1120             expectCommand(stream, imeSession.callSetImeCaptionBarVisible(false), TIMEOUT);
1121             expectImeVisible(TIMEOUT);
1122         }
1123     }
1124 
1125     /**
1126      * Verifies that requesting to hide the IME caption bar and then show it again does not lead
1127      * to any undesired behaviour (e.g. crashing, hiding the IME when it was visible, etc.).
1128      */
1129     @Test
1130     public void testRequestHideThenShowImeCaptionBar() throws Exception {
1131         try (MockImeSession imeSession = MockImeSession.create(
1132                 InstrumentationRegistry.getInstrumentation().getContext(),
1133                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
1134                 new ImeSettings.Builder())) {
1135             final ImeEventStream stream = imeSession.openEventStream();
1136 
1137             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1138             expectEvent(stream, editorMatcher("onStartInput", mMarker), TIMEOUT);
1139             expectImeVisible(TIMEOUT);
1140 
1141             expectCommand(stream, imeSession.callSetImeCaptionBarVisible(false), TIMEOUT);
1142             expectImeVisible(TIMEOUT);
1143 
1144             expectCommand(stream, imeSession.callSetImeCaptionBarVisible(true), TIMEOUT);
1145             expectImeVisible(TIMEOUT);
1146         }
1147     }
1148 
1149     /**
1150      * Checks that the IME insets are at least as big as the IME navigation bar (when visible),
1151      * even if the IME overrides the insets, or gives an empty input view.
1152      */
1153     @Test
1154     public void testImeNavigationBarInsets() throws Exception {
1155         runImeNavigationBarTest(false /* useFullscreenMode */);
1156     }
1157 
1158     /**
1159      * Checks that the IME insets are not modified to be at least as big as the IME navigation bar,
1160      * when the IME is using fullscreen mode.
1161      */
1162     @Test
1163     public void testImeNavigationBarInsets_FullscreenMode() throws Exception {
1164         runImeNavigationBarTest(true /* useFullscreenMode */);
1165     }
1166 
1167     /**
1168      * Test implementation for checking that the IME insets are at least as big as the IME
1169      * navigation bar (when visible). When using fullscreen mode, the IME requesting app should
1170      * receive zero IME insets.
1171      *
1172      * @param useFullscreenMode whether the IME should use the fullscreen mode.
1173      */
1174     private void runImeNavigationBarTest(boolean useFullscreenMode) throws Exception {
1175         try (MockImeSession imeSession = MockImeSession.create(
1176                 InstrumentationRegistry.getInstrumentation().getContext(),
1177                 InstrumentationRegistry.getInstrumentation().getUiAutomation(),
1178                 new ImeSettings.Builder()
1179                         .setZeroInsets(true)
1180                         .setDrawsBehindNavBar(true)
1181                         .setFullscreenModePolicy(
1182                                 useFullscreenMode
1183                                         ? ImeSettings.FullscreenModePolicy.FORCE_FULLSCREEN
1184                                         : ImeSettings.FullscreenModePolicy.NO_FULLSCREEN))) {
1185             final ImeEventStream stream = imeSession.openEventStream();
1186 
1187             final var activity = createTestActivity(SOFT_INPUT_STATE_ALWAYS_HIDDEN);
1188             final var decorView = activity.getWindow().getDecorView();
1189             int imeHeight = decorView.getRootWindowInsets().getInsets(ime()).bottom;
1190 
1191             assertEquals(0, imeHeight);
1192 
1193             imeSession.callRequestShowSelf(0 /* flags */);
1194 
1195             PollingCheck.waitFor(TIMEOUT, () -> decorView.getRootWindowInsets().isVisible(ime()));
1196 
1197             imeHeight = decorView.getRootWindowInsets().getInsets(ime()).bottom;
1198             final boolean isFullscreen = expectCommand(stream,
1199                     imeSession.callGetOnEvaluateFullscreenMode(), TIMEOUT)
1200                     .getReturnBooleanValue();
1201             assertEquals(isFullscreen, useFullscreenMode);
1202             if (isFullscreen) {
1203                 // In Fullscreen mode the IME doesn't provide any insets.
1204                 assertEquals("Height of ime: " + imeHeight + " should be zero in fullscreen mode",
1205                         0, imeHeight);
1206             } else {
1207                 final int imeNavBarHeight = expectCommand(stream,
1208                         imeSession.callGetImeCaptionBarHeight(), TIMEOUT)
1209                         .getReturnIntegerValue();
1210                 assertTrue("Height of ime: " + imeHeight + " should be at least as big as"
1211                                 + " the height of the IME navigation bar: " + imeNavBarHeight,
1212                         imeHeight >= imeNavBarHeight);
1213             }
1214         }
1215     }
1216 
1217     /** Explicitly start-up the IME process if it would have been prevented. */
1218     protected void ensureImeRunning() {
1219         if (isPreventImeStartup()) {
1220             createTestActivity(SOFT_INPUT_STATE_ALWAYS_VISIBLE);
1221         }
1222     }
1223 
1224     /** Test case for committing and setting composing region after cursor. */
1225     private static UpdateSelectionTest getCommitAndSetComposingRegionTest(
1226             long timeout, String makerPrefix) throws Exception {
1227         UpdateSelectionTest test = new UpdateSelectionTest(timeout, makerPrefix) {
1228             @Override
1229             public void testMethodImpl() throws Exception {
1230                 // "abc|"
1231                 expectCommand(stream, imeSession.callCommitText("abc", 1), timeout);
1232                 verifyText("abc", 3, 3);
1233                 final Bundle arguments1 = expectEvent(stream,
1234                         eventMatcher("onUpdateSelection"),
1235                         timeout).getArguments();
1236                 expectOnUpdateSelectionArguments(arguments1, 0, 0, 3, 3, -1, -1);
1237                 notExpectEvent(stream,
1238                         eventMatcher("onUpdateSelection"),
1239                         EXPECTED_TIMEOUT);
1240 
1241                 // "|abc"
1242                 expectCommand(stream, imeSession.callSetSelection(0, 0), timeout);
1243                 verifyText("abc", 0, 0);
1244                 final Bundle arguments2 = expectEvent(stream,
1245                         eventMatcher("onUpdateSelection"),
1246                         timeout).getArguments();
1247                 expectOnUpdateSelectionArguments(arguments2, 3, 3, 0, 0, -1, -1);
1248                 notExpectEvent(stream,
1249                         eventMatcher("onUpdateSelection"),
1250                         EXPECTED_TIMEOUT);
1251 
1252                 // "Back |abc"
1253                 //        ---
1254                 expectCommand(stream, imeSession.callBeginBatchEdit(), timeout);
1255                 expectCommand(stream, imeSession.callCommitText("Back ", 1), timeout);
1256                 expectCommand(stream, imeSession.callSetComposingRegion(5, 8), timeout);
1257                 expectCommand(stream, imeSession.callEndBatchEdit(), timeout);
1258                 verifyText("Back abc", 5, 5);
1259                 final Bundle arguments3 = expectEvent(stream,
1260                         eventMatcher("onUpdateSelection"),
1261                         timeout).getArguments();
1262                 expectOnUpdateSelectionArguments(arguments3, 0, 0, 5, 5, 5, 8);
1263                 notExpectEvent(stream,
1264                         eventMatcher("onUpdateSelection"),
1265                         EXPECTED_TIMEOUT);
1266             }
1267         };
1268         return test;
1269     }
1270 
1271     /** Test case for committing space and setting composing region after cursor. */
1272     private static UpdateSelectionTest getCommitSpaceAndSetComposingRegionTest(
1273             long timeout, String makerPrefix) throws Exception {
1274         UpdateSelectionTest test = new UpdateSelectionTest(timeout, makerPrefix) {
1275             @Override
1276             public void testMethodImpl() throws Exception {
1277                 // "Hello|"
1278                 //  -----
1279                 expectCommand(stream, imeSession.callSetComposingText("Hello", 1), timeout);
1280                 verifyText("Hello", 5, 5);
1281                 final Bundle arguments1 = expectEvent(stream,
1282                         eventMatcher("onUpdateSelection"),
1283                         timeout).getArguments();
1284                 expectOnUpdateSelectionArguments(arguments1, 0, 0, 5, 5, 0, 5);
1285                 notExpectEvent(stream,
1286                         eventMatcher("onUpdateSelection"),
1287                         EXPECTED_TIMEOUT);
1288 
1289                 // "|Hello"
1290                 //   -----
1291                 expectCommand(stream, imeSession.callSetSelection(0, 0), timeout);
1292                 verifyText("Hello", 0, 0);
1293                 final Bundle arguments2 = expectEvent(stream,
1294                         eventMatcher("onUpdateSelection"),
1295                         timeout).getArguments();
1296                 expectOnUpdateSelectionArguments(arguments2, 5, 5, 0, 0, 0, 5);
1297                 notExpectEvent(stream,
1298                         eventMatcher("onUpdateSelection"),
1299                         EXPECTED_TIMEOUT);
1300 
1301                 // " |Hello"
1302                 //    -----
1303                 expectCommand(stream, imeSession.callBeginBatchEdit(), timeout);
1304                 expectCommand(stream, imeSession.callFinishComposingText(), timeout);
1305                 expectCommand(stream, imeSession.callCommitText(" ", 1), timeout);
1306                 expectCommand(stream, imeSession.callSetComposingRegion(1, 6), timeout);
1307                 expectCommand(stream, imeSession.callEndBatchEdit(), timeout);
1308 
1309                 verifyText(" Hello", 1, 1);
1310                 final Bundle arguments3 = expectEvent(stream,
1311                         eventMatcher("onUpdateSelection"),
1312                         timeout).getArguments();
1313                 expectOnUpdateSelectionArguments(arguments3, 0, 0, 1, 1, 1, 6);
1314                 notExpectEvent(stream,
1315                         eventMatcher("onUpdateSelection"),
1316                         EXPECTED_TIMEOUT);
1317             }
1318         };
1319         return test;
1320     }
1321 
1322     /**
1323      * Test case for committing space in the middle of selection and setting composing region after
1324      * cursor.
1325      */
1326     private static UpdateSelectionTest getCommitSpaceAndSetComposingRegionInSelectionTest(
1327             long timeout, String makerPrefix) throws Exception {
1328         UpdateSelectionTest test = new UpdateSelectionTest(timeout, makerPrefix) {
1329             @Override
1330             public void testMethodImpl() throws Exception {
1331                 // "2005abc|"
1332                 expectCommand(stream, imeSession.callCommitText("2005abc", 1), timeout);
1333                 verifyText("2005abc", 7, 7);
1334                 final Bundle arguments1 = expectEvent(stream,
1335                         eventMatcher("onUpdateSelection"),
1336                         timeout).getArguments();
1337                 expectOnUpdateSelectionArguments(arguments1, 0, 0, 7, 7, -1, -1);
1338                 notExpectEvent(stream,
1339                         eventMatcher("onUpdateSelection"),
1340                         EXPECTED_TIMEOUT);
1341 
1342                 // "2005|abc"
1343                 expectCommand(stream, imeSession.callSetSelection(4, 4), timeout);
1344                 verifyText("2005abc", 4, 4);
1345                 final Bundle arguments2 = expectEvent(stream,
1346                         eventMatcher("onUpdateSelection"),
1347                         timeout).getArguments();
1348                 expectOnUpdateSelectionArguments(arguments2, 7, 7, 4, 4, -1, -1);
1349                 notExpectEvent(stream,
1350                         eventMatcher("onUpdateSelection"),
1351                         EXPECTED_TIMEOUT);
1352 
1353                 // "2005 |abc"
1354                 //        ---
1355                 expectCommand(stream, imeSession.callBeginBatchEdit(), timeout);
1356                 expectCommand(stream, imeSession.callCommitText(" ", 1), timeout);
1357                 expectCommand(stream, imeSession.callSetComposingRegion(5, 8), timeout);
1358                 expectCommand(stream, imeSession.callEndBatchEdit(), timeout);
1359 
1360                 verifyText("2005 abc", 5, 5);
1361                 final Bundle arguments3 = expectEvent(stream,
1362                         eventMatcher("onUpdateSelection"),
1363                         timeout).getArguments();
1364                 expectOnUpdateSelectionArguments(arguments3, 4, 4, 5, 5, 5, 8);
1365                 notExpectEvent(stream,
1366                         eventMatcher("onUpdateSelection"),
1367                         EXPECTED_TIMEOUT);
1368             }
1369         };
1370         return test;
1371     }
1372 
1373     private static void expectOnUpdateSelectionArguments(Bundle arguments,
1374             int expectedOldSelStart, int expectedOldSelEnd, int expectedNewSelStart,
1375             int expectedNewSelEnd, int expectedCandidateStart, int expectedCandidateEnd) {
1376         assertEquals(expectedOldSelStart, arguments.getInt("oldSelStart"));
1377         assertEquals(expectedOldSelEnd, arguments.getInt("oldSelEnd"));
1378         assertEquals(expectedNewSelStart, arguments.getInt("newSelStart"));
1379         assertEquals(expectedNewSelEnd, arguments.getInt("newSelEnd"));
1380         assertEquals(expectedCandidateStart, arguments.getInt("candidatesStart"));
1381         assertEquals(expectedCandidateEnd, arguments.getInt("candidatesEnd"));
1382     }
1383 
1384     /**
1385      * Helper class for wrapping tests for {@link android.widget.TextView} and @{@link WebView}
1386      * relates to batch edit and update selection change.
1387      */
1388     private abstract static class UpdateSelectionTest {
1389         private final long mTimeout;
1390         private final String mMaker;
1391         private final AtomicReference<EditText> mEditTextRef = new AtomicReference<>();
1392         private final AtomicReference<UiObject2> mInputTextFieldRef = new AtomicReference<>();
1393 
1394         public final MockImeSession imeSession;
1395         public final ImeEventStream stream;
1396 
1397         // True if testing TextView, otherwise test WebView
1398         private boolean mIsTestingTextView;
1399 
1400         UpdateSelectionTest(long timeout, String makerPrefix) throws Exception {
1401             this.mTimeout = timeout;
1402             this.mMaker = makerPrefix + SystemClock.elapsedRealtimeNanos();
1403             imeSession = MockImeSession.create(
1404                     InstrumentationRegistry.getInstrumentation().getContext(),
1405                     InstrumentationRegistry.getInstrumentation().getUiAutomation(),
1406                     new ImeSettings.Builder());
1407             stream = imeSession.openEventStream();
1408         }
1409 
1410         /**
1411          * Runs the real test logic, which would test onStartInput event first, then test the logic
1412          * in {@link #testMethodImpl()}.
1413          *
1414          * @throws Exception if timeout or assert fails
1415          */
1416         public void runTest() throws Exception {
1417             if (mIsTestingTextView) {
1418                 TestActivity.startSync(activity -> {
1419                     final LinearLayout layout = new LinearLayout(activity);
1420                     layout.setOrientation(LinearLayout.VERTICAL);
1421                     final EditText editText = new EditText(activity);
1422                     layout.addView(editText);
1423                     editText.requestFocus();
1424                     editText.setPrivateImeOptions(mMaker);
1425                     mEditTextRef.set(editText);
1426                     return layout;
1427                 });
1428                 assertNotNull(mEditTextRef.get());
1429             } else {
1430                 final UiObject2 inputTextField = TestWebView.launchTestWebViewActivity(
1431                         mTimeout, mMaker);
1432                 assertNotNull("Editor must exists on WebView", inputTextField);
1433                 mInputTextFieldRef.set(inputTextField);
1434                 inputTextField.click();
1435             }
1436             expectEvent(stream, editorMatcher("onStartInput", mMaker), TIMEOUT);
1437 
1438             // Code for testing input connection logic.
1439             testMethodImpl();
1440         }
1441 
1442         /**
1443          * Test method to be overridden by implementation class.
1444          */
1445         public abstract void testMethodImpl() throws Exception;
1446 
1447         /**
1448          * Verifies text and selection range in the edit text if this is running tests for TextView;
1449          * otherwise verifies the text (no selection) in the WebView.
1450          * @param expectedText expected text in the TextView or WebView
1451          * @param selStart expected start position of the selection in the TextView; will be ignored
1452          *                 for WebView
1453          * @param selEnd expected end position of the selection in the WebView; will be ignored for
1454          *               WebView
1455          * @throws Exception if timeout or assert fails
1456          */
1457         public void verifyText(String expectedText, int selStart, int selEnd) throws Exception {
1458             if (mIsTestingTextView) {
1459                 EditText editText = mEditTextRef.get();
1460                 assertNotNull(editText);
1461                 waitOnMainUntil(()->
1462                         expectedText.equals(editText.getText().toString())
1463                                 && selStart == editText.getSelectionStart()
1464                                 && selEnd == editText.getSelectionEnd(), mTimeout);
1465             } else {
1466                 UiObject2 inputTextField = mInputTextFieldRef.get();
1467                 assertNotNull(inputTextField);
1468                 waitOnMainUntil(()-> expectedText.equals(inputTextField.getText()), mTimeout);
1469             }
1470         }
1471 
1472         public UpdateSelectionTest setTestTextView(boolean isTestingTextView) {
1473             this.mIsTestingTextView = isTestingTextView;
1474             return this;
1475         }
1476     }
1477 
1478     private static WindowLayoutInfoParcelable verifyReceivedWindowLayout(ImeEventStream stream)
1479             throws TimeoutException {
1480         final ImeEvent imeEvent = expectEvent(stream,
1481                 eventMatcher("getWindowLayoutInfo"),
1482                 TIMEOUT);
1483         return imeEvent.getArguments()
1484                 .getParcelable("WindowLayoutInfo", WindowLayoutInfoParcelable.class);
1485     }
1486 
1487     /**
1488      * Checks if the resizing of a rectangle maintains a specified aspect ratio.
1489      * <p>
1490      * This function compares the initial and resized dimensions of a rectangle, using a provided
1491      * function ({@code getSize}) to extract the relevant dimension (e.g., width or height).
1492      * It determines if the resized dimension matches the expected value, calculated as the
1493      * initial dimension multiplied by the given ratio.
1494      * <p>
1495      * If the aspect ratio is not maintained, an error message detailing the discrepancy is
1496      * appended to the {@code outErrorMessage} StringBuilder.
1497      *
1498      * @param getSize a function that extracts the relevant dimension (width or height) from a Rect.
1499      * @param initial the initial Rect before resizing.
1500      * @param resized the Rect after resizing.
1501      * @param ratio the expected resize ratio (e.g., 0.8 for a 20% decrease).
1502      * @param outErrorMessage a StringBuilder to which an error message is appended if the aspect
1503      *                        ratio is not maintained.
1504      * @return {@code true} if the resize maintains the specified ratio, {@code false} otherwise.
1505      */
1506     private static boolean isResizedWithRatio(@NonNull Function<Rect, Integer> getSize,
1507             @NonNull Rect initial, @NonNull Rect resized, double ratio,
1508             @NonNull StringBuilder outErrorMessage) {
1509         // Align with the rounding approach in DisplayMetricsSession#changeDisplayMetrics.
1510         final int expected = (int) (getSize.apply(initial) * ratio);
1511         final int actual = getSize.apply(resized);
1512         final boolean isCorrect = (expected == actual);
1513         if (!isCorrect) {
1514             outErrorMessage
1515                     .append("\n  isResizedWithRatio(initial=")
1516                     .append(initial)
1517                     .append(", resized=")
1518                     .append(resized)
1519                     .append(", ratio=")
1520                     .append(ratio)
1521                     .append("):\n    expected size: ")
1522                     .append(expected)
1523                     .append(" but was: ")
1524                     .append(actual);
1525         }
1526         return isCorrect;
1527     }
1528 }
1529