1 /*
2  * Copyright (C) 2021 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.view.inputmethod.cts.util.TestUtils.getOnMainSync;
20 import static android.view.inputmethod.cts.util.TestUtils.runOnMainSync;
21 import static android.view.inputmethod.cts.util.TestUtils.runOnMainSyncWithRethrowing;
22 
23 import static com.android.cts.mockime.ImeEventStreamTestUtils.editorMatcher;
24 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectBindInput;
25 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectCommand;
26 import static com.android.cts.mockime.ImeEventStreamTestUtils.expectEvent;
27 
28 import static org.junit.Assert.assertEquals;
29 import static org.junit.Assert.assertFalse;
30 import static org.junit.Assert.assertTrue;
31 import static org.junit.Assert.fail;
32 
33 import android.app.Instrumentation;
34 import android.content.Context;
35 import android.graphics.Color;
36 import android.os.Bundle;
37 import android.os.Handler;
38 import android.os.HandlerThread;
39 import android.os.Looper;
40 import android.os.Process;
41 import android.platform.test.annotations.AppModeSdkSandbox;
42 import android.system.Os;
43 import android.text.InputType;
44 import android.view.View;
45 import android.view.ViewGroup;
46 import android.view.inputmethod.EditorInfo;
47 import android.view.inputmethod.InputConnection;
48 import android.view.inputmethod.InputMethodManager;
49 import android.view.inputmethod.SurroundingText;
50 import android.view.inputmethod.cts.util.EndToEndImeTestBase;
51 import android.view.inputmethod.cts.util.HandlerInputConnection;
52 import android.view.inputmethod.cts.util.MockTestActivityUtil;
53 import android.view.inputmethod.cts.util.TestActivity;
54 import android.widget.EditText;
55 import android.widget.LinearLayout;
56 
57 import androidx.annotation.NonNull;
58 import androidx.test.filters.LargeTest;
59 import androidx.test.platform.app.InstrumentationRegistry;
60 import androidx.test.runner.AndroidJUnit4;
61 
62 import com.android.cts.mockime.ImeCommand;
63 import com.android.cts.mockime.ImeEvent;
64 import com.android.cts.mockime.ImeEventStream;
65 import com.android.cts.mockime.ImeSettings;
66 import com.android.cts.mockime.MockImeSession;
67 
68 import org.junit.Test;
69 import org.junit.runner.RunWith;
70 
71 import java.util.Objects;
72 import java.util.concurrent.CountDownLatch;
73 import java.util.concurrent.TimeUnit;
74 import java.util.concurrent.atomic.AtomicInteger;
75 import java.util.concurrent.atomic.AtomicReference;
76 
77 /**
78  * Tests the thread-affinity in {@link InputConnection} callbacks provided by
79  * {@link InputConnection#getHandler()}.
80  *
81  * <p>TODO: Add more tests.</p>
82  */
83 @LargeTest
84 @RunWith(AndroidJUnit4.class)
85 @AppModeSdkSandbox(reason = "Allow test in the SDK sandbox (does not prevent other modes).")
86 public class InputConnectionHandlerTest extends EndToEndImeTestBase {
87     private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(5);
88 
89     /**
90      * The value used in android.inputmethodservice.RemoteInputConnection#MAX_WAIT_TIME_MILLIS.
91      *
92      * <p>Although this is not a strictly-enforced timeout for all the Android devices, hopefully
93      * it'd be acceptable to assume that IMEs can receive result within 2 second even on slower
94      * devices.</p>
95      *
96      * <p>TODO: Consider making this as a test API.</p>
97      */
98     private static final long TIMEOUT_IN_REMOTE_INPUT_CONNECTION =
99             TimeUnit.MILLISECONDS.toMillis(2000);
100 
101     private static final int TEST_VIEW_HEIGHT = 10;
102 
103     private static final class InputConnectionHandlingThread extends HandlerThread
104             implements AutoCloseable {
105 
106         private final Handler mHandler;
107 
InputConnectionHandlingThread()108         InputConnectionHandlingThread() {
109             super("IC-callback");
110             start();
111             mHandler = Handler.createAsync(getLooper());
112         }
113 
114         @NonNull
getHandler()115         Handler getHandler() {
116             return mHandler;
117         }
118 
119         @Override
close()120         public void close() {
121             quitSafely();
122             try {
123                 join(TIMEOUT);
124             } catch (InterruptedException e) {
125                 fail("Failed to stop the thread: " + e);
126             }
127         }
128     }
129 
130     /**
131      * A mostly-minimum implementation of {@link View} that can be used to test custom
132      * implementations of {@link View#onCreateInputConnection(EditorInfo)}.
133      */
134     static class TestEditor extends View {
TestEditor(@onNull Context context)135         TestEditor(@NonNull Context context) {
136             super(context);
137             setBackgroundColor(Color.YELLOW);
138             setFocusableInTouchMode(true);
139             setFocusable(true);
140             setLayoutParams(new ViewGroup.LayoutParams(
141                     ViewGroup.LayoutParams.MATCH_PARENT, TEST_VIEW_HEIGHT));
142         }
143     }
144 
145     /**
146      * Test {@link InputConnection#commitText(CharSequence, int)} respects
147      * {@link InputConnection#getHandler()}.
148      */
149     @Test
testCommitText()150     public void testCommitText() throws Exception {
151         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
152              MockImeSession imeSession = MockImeSession.create(
153                      InstrumentationRegistry.getInstrumentation().getContext(),
154                      InstrumentationRegistry.getInstrumentation().getUiAutomation(),
155                      new ImeSettings.Builder())) {
156 
157             final AtomicInteger callingThreadId = new AtomicInteger(0);
158             final CountDownLatch latch = new CountDownLatch(1);
159 
160             final class MyInputConnection extends HandlerInputConnection {
161                 MyInputConnection() {
162                     super(thread.getHandler());
163                 }
164 
165                 @Override
166                 public boolean commitText(CharSequence text, int newCursorPosition) {
167                     callingThreadId.set(Os.gettid());
168                     latch.countDown();
169                     return super.commitText(text, newCursorPosition);
170                 }
171             }
172 
173             final ImeEventStream stream = imeSession.openEventStream();
174 
175             final String marker = getTestMarker();
176 
177             TestActivity.startSync(activity -> {
178                 final LinearLayout layout = new LinearLayout(activity);
179                 layout.setOrientation(LinearLayout.VERTICAL);
180 
181                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
182                 // injecting our custom InputConnection implementation.
183                 final TestEditor testEditor = new TestEditor(activity) {
184                     @Override
185                     public boolean onCheckIsTextEditor() {
186                         return imeSession.isActive();
187                     }
188 
189                     @Override
190                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
191                         if (imeSession.isActive()) {
192                             outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
193                             outAttrs.privateImeOptions = marker;
194                             return new MyInputConnection();
195                         }
196                         return null;
197                     }
198                 };
199 
200                 testEditor.requestFocus();
201                 layout.addView(testEditor);
202                 return layout;
203             });
204 
205             // Wait until the MockIme gets bound to the TestActivity.
206             expectBindInput(stream, Process.myPid(), TIMEOUT);
207 
208             // Wait until "onStartInput" gets called for the EditText.
209             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
210 
211             final ImeCommand command = imeSession.callCommitText("", 1);
212             expectCommand(stream, command, TIMEOUT);
213 
214             assertTrue("commitText() must be called", latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
215 
216             assertEquals("commitText() must happen on the handler thread",
217                     thread.getThreadId(), callingThreadId.get());
218         }
219     }
220 
221     /**
222      * Test {@link InputConnection#closeConnection()} gets called on the associated thread after
223      * {@link InputMethodManager#restartInput(View)}.
224      *
225      * @see InputConnectionLifecycleTest#testCloseConnectionWithRestartInput()
226      */
227     @Test
testCloseConnectionWithRestartInput()228     public void testCloseConnectionWithRestartInput() throws Exception {
229         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
230              MockImeSession imeSession = MockImeSession.create(
231                      InstrumentationRegistry.getInstrumentation().getContext(),
232                      InstrumentationRegistry.getInstrumentation().getUiAutomation(),
233                      new ImeSettings.Builder())) {
234 
235             final CountDownLatch latch = new CountDownLatch(1);
236             final AtomicInteger callingThreadId = new AtomicInteger(0);
237 
238             final ImeEventStream stream = imeSession.openEventStream();
239 
240             final String marker = getTestMarker();
241 
242             final AtomicReference<TestEditor> testEditorRef = new AtomicReference<>();
243 
244             TestActivity.startSync(activity -> {
245                 final LinearLayout layout = new LinearLayout(activity);
246                 layout.setOrientation(LinearLayout.VERTICAL);
247 
248                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
249                 // injecting our custom InputConnection implementation.
250                 final TestEditor testEditor = new TestEditor(activity) {
251                     @Override
252                     public boolean onCheckIsTextEditor() {
253                         return imeSession.isActive();
254                     }
255 
256                     @Override
257                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
258                         if (!imeSession.isActive()) {
259                             return null;
260                         }
261                         outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
262                         outAttrs.privateImeOptions = marker;
263                         return new HandlerInputConnection(thread.getHandler()) {
264                             @Override
265                             public void closeConnection() {
266                                 if (callingThreadId.compareAndExchange(0, Os.gettid()) == 0) {
267                                     latch.countDown();
268                                 }
269                                 super.closeConnection();
270                             }
271                         };
272                     }
273                 };
274                 testEditorRef.set(testEditor);
275 
276                 testEditor.requestFocus();
277                 layout.addView(testEditor);
278 
279                 return layout;
280             });
281 
282             // Wait until "onStartInput" gets called for the EditText.
283             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
284             assertEquals(1, latch.getCount());
285 
286             runOnMainSync(() -> {
287                 final TestEditor testEditor = testEditorRef.get();
288                 final InputMethodManager imm = Objects.requireNonNull(
289                         testEditor.getContext().getSystemService(InputMethodManager.class));
290                 imm.restartInput(testEditor);
291             });
292 
293             assertTrue("closeConnection() must be called",
294                     latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
295             assertEquals("closeConnection() must happen on the handler thread",
296                     thread.getThreadId(), callingThreadId.get());
297         }
298     }
299 
300     /**
301      * Test {@link InputConnection#closeConnection()} gets called on the associated thread after
302      * losing the {@link View} focus.
303      *
304      * @see InputConnectionLifecycleTest#testCloseConnectionWithLosingViewFocus()
305      */
306     @Test
testCloseConnectionWithLosingViewFocus()307     public void testCloseConnectionWithLosingViewFocus() throws Exception {
308         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
309              MockImeSession imeSession = MockImeSession.create(
310                      InstrumentationRegistry.getInstrumentation().getContext(),
311                      InstrumentationRegistry.getInstrumentation().getUiAutomation(),
312                      new ImeSettings.Builder())) {
313 
314             final CountDownLatch latch = new CountDownLatch(1);
315             final AtomicInteger callingThreadId = new AtomicInteger(0);
316 
317             final ImeEventStream stream = imeSession.openEventStream();
318 
319             final String marker = getTestMarker();
320 
321             final AtomicReference<EditText> anotherEditTextRef = new AtomicReference<>();
322 
323             TestActivity.startSync(activity -> {
324                 final LinearLayout layout = new LinearLayout(activity);
325                 layout.setOrientation(LinearLayout.VERTICAL);
326 
327                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
328                 // injecting our custom InputConnection implementation.
329                 final TestEditor testEditor = new TestEditor(activity) {
330                     @Override
331                     public boolean onCheckIsTextEditor() {
332                         return imeSession.isActive();
333                     }
334 
335                     @Override
336                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
337                         if (!imeSession.isActive()) {
338                             return null;
339                         }
340                         outAttrs.privateImeOptions = marker;
341                         return new HandlerInputConnection(thread.getHandler()) {
342                             @Override
343                             public void closeConnection() {
344                                 if (callingThreadId.compareAndExchange(0, Os.gettid()) == 0) {
345                                     latch.countDown();
346                                 }
347                                 super.closeConnection();
348                             }
349                         };
350                     }
351                 };
352 
353                 testEditor.requestFocus();
354                 layout.addView(testEditor);
355 
356                 final EditText editText = new EditText(activity);
357                 layout.addView(editText);
358 
359                 anotherEditTextRef.set(editText);
360 
361                 return layout;
362             });
363 
364             // Wait until "onStartInput" gets called for the EditText.
365             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
366             assertEquals(1, latch.getCount());
367 
368             runOnMainSync(() -> anotherEditTextRef.get().requestFocus());
369 
370             assertTrue("closeConnection() must be called",
371                     latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
372             assertEquals("closeConnection() must happen on the handler thread",
373                     thread.getThreadId(), callingThreadId.get());
374         }
375     }
376 
377     /**
378      * Test {@link InputConnection#closeConnection()} gets called on the associated thread after
379      * losing the {@link android.view.Window} focus.
380      *
381      * @see InputConnectionLifecycleTest#testCloseConnectionWithLosingWindowFocus()
382      */
383     @Test
testCloseConnectionWithLosingWindowFocus()384     public void testCloseConnectionWithLosingWindowFocus() throws Exception {
385         final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
386         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
387              MockImeSession imeSession = MockImeSession.create(
388                      instrumentation.getContext(),
389                      instrumentation.getUiAutomation(),
390                      new ImeSettings.Builder())) {
391 
392             final CountDownLatch latch = new CountDownLatch(1);
393             final AtomicInteger callingThreadId = new AtomicInteger(0);
394 
395             final ImeEventStream stream = imeSession.openEventStream();
396 
397             final String marker = getTestMarker();
398 
399             TestActivity.startSync(activity -> {
400                 final LinearLayout layout = new LinearLayout(activity);
401                 layout.setOrientation(LinearLayout.VERTICAL);
402 
403                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
404                 // injecting our custom InputConnection implementation.
405                 final TestEditor testEditor = new TestEditor(activity) {
406                     @Override
407                     public boolean onCheckIsTextEditor() {
408                         return imeSession.isActive();
409                     }
410 
411                     @Override
412                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
413                         if (!imeSession.isActive()) {
414                             return null;
415                         }
416                         outAttrs.privateImeOptions = marker;
417                         return new HandlerInputConnection(thread.getHandler()) {
418                             @Override
419                             public void closeConnection() {
420                                 if (callingThreadId.compareAndExchange(0, Os.gettid()) == 0) {
421                                     latch.countDown();
422                                 }
423                                 super.closeConnection();
424                             }
425                         };
426                     }
427                 };
428 
429                 testEditor.requestFocus();
430                 layout.addView(testEditor);
431 
432                 return layout;
433             });
434 
435             // Wait until "onStartInput" gets called for the EditText.
436             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
437             assertEquals(1, latch.getCount());
438 
439             // Launch a new Activity in a different process.
440             final boolean instant =
441                     instrumentation.getTargetContext().getPackageManager().isInstantApp();
442             try (AutoCloseable unused = MockTestActivityUtil.launchSync(instant, TIMEOUT)) {
443                 assertTrue("closeConnection() must be called",
444                         latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
445                 assertEquals("closeConnection() must happen on the handler thread",
446                         thread.getThreadId(), callingThreadId.get());
447             }
448         }
449     }
450 
451     /**
452      * Test {@link InputConnection#reportFullscreenMode(boolean)} respects
453      * {@link InputConnection#getHandler()}.
454      */
455     @Test
testReportFullscreenMode()456     public void testReportFullscreenMode() throws Exception {
457         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
458              MockImeSession imeSession = MockImeSession.create(
459                      InstrumentationRegistry.getInstrumentation().getContext(),
460                      InstrumentationRegistry.getInstrumentation().getUiAutomation(),
461                      new ImeSettings.Builder().setFullscreenModePolicy(
462                              ImeSettings.FullscreenModePolicy.FORCE_FULLSCREEN))) {
463 
464             final AtomicInteger callingThreadId = new AtomicInteger(0);
465             final CountDownLatch latch = new CountDownLatch(1);
466 
467             final class MyInputConnection extends HandlerInputConnection {
468                 MyInputConnection() {
469                     super(thread.getHandler());
470                 }
471 
472                 @Override
473                 public boolean reportFullscreenMode(boolean enabled) {
474                     callingThreadId.set(Os.gettid());
475                     latch.countDown();
476                     return true;
477                 }
478             }
479 
480             final ImeEventStream stream = imeSession.openEventStream();
481 
482             final String marker = getTestMarker();
483 
484             final AtomicReference<View> testEditorViewRef = new AtomicReference<>();
485             TestActivity.startSync(activity -> {
486                 final LinearLayout layout = new LinearLayout(activity);
487                 layout.setOrientation(LinearLayout.VERTICAL);
488 
489                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
490                 // injecting our custom InputConnection implementation.
491                 final TestEditor testEditor = new TestEditor(activity) {
492                     @Override
493                     public boolean onCheckIsTextEditor() {
494                         return imeSession.isActive();
495                     }
496 
497                     @Override
498                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
499                         if (imeSession.isActive()) {
500                             outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
501                             outAttrs.privateImeOptions = marker;
502                             return new MyInputConnection();
503                         }
504                         return null;
505                     }
506                 };
507 
508                 testEditor.requestFocus();
509                 testEditorViewRef.set(testEditor);
510                 layout.addView(testEditor);
511                 return layout;
512             });
513 
514             // Wait until the MockIme gets bound to the TestActivity.
515             expectBindInput(stream, Process.myPid(), TIMEOUT);
516 
517             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
518 
519             assertFalse("InputMethodManager#isFullscreenMode() must return false",
520                     getOnMainSync(() -> InstrumentationRegistry.getInstrumentation().getContext()
521                             .getSystemService(InputMethodManager.class).isFullscreenMode()));
522 
523             // In order to have an IME be shown in the fullscreen mode,
524             // SOFT_INPUT_STATE_ALWAYS_VISIBLE is insufficient.  An explicit API call is necessary.
525             runOnMainSync(() -> {
526                 final View editor = testEditorViewRef.get();
527                 editor.getContext().getSystemService(InputMethodManager.class)
528                         .showSoftInput(editor, 0);
529             });
530 
531             expectEvent(stream, editorMatcher("onStartInputView", marker), TIMEOUT);
532 
533             assertTrue("reportFullscreenMode() must be called",
534                     latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
535 
536             assertEquals("reportFullscreenMode() must happen on the handler thread",
537                     thread.getThreadId(), callingThreadId.get());
538 
539             assertTrue("InputMethodManager#isFullscreenMode() must return true",
540                     getOnMainSync(() -> InstrumentationRegistry.getInstrumentation().getContext()
541                             .getSystemService(InputMethodManager.class).isFullscreenMode()));
542             assertTrue(expectCommand(stream, imeSession.callVerifyExtractViewNotNull(), TIMEOUT)
543                     .getReturnBooleanValue());
544         }
545     }
546 
547     /**
548      * Make sure that handling incoming {@link InputConnection} tasks in a background thread while
549      * the IME focus is being updated does not accelerate IME focus handling process.
550      *
551      * <p>Test case inspired from Bug 286470800 and Bug 283517132.</p>
552      */
553     @Test
testInputConnectionSideEffect()554     public void testInputConnectionSideEffect() throws Exception {
555         final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
556         try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread();
557                 MockImeSession imeSession = MockImeSession.create(
558                 instrumentation.getContext(),
559                 instrumentation.getUiAutomation(),
560                 new ImeSettings.Builder())) {
561             final ImeEventStream stream = imeSession.openEventStream();
562             final String editTextMarker = getTestMarker();
563             final String fenceMarker = getTestMarker("Fence");
564             final AtomicReference<Runnable> removeViewRef = new AtomicReference<>();
565             final CountDownLatch fenceCommandLatch = new CountDownLatch(1);
566             final CountDownLatch closeConnectionLatch = new CountDownLatch(1);
567 
568             final class MyInputConnection extends HandlerInputConnection {
569                 MyInputConnection() {
570                     super(thread.getHandler());
571                 }
572 
573                 @Override
574                 public boolean performPrivateCommand(String action, Bundle data) {
575                     if (fenceMarker.equals(action)) {
576                         fenceCommandLatch.countDown();
577                         return true;
578                     }
579                     return false;
580                 }
581 
582                 @Override
583                 public void closeConnection() {
584                     closeConnectionLatch.countDown();
585                     super.closeConnection();
586                 }
587             }
588 
589             // Launch test activity
590             TestActivity.startSync(activity -> {
591                 final LinearLayout layout = new LinearLayout(activity);
592                 layout.setOrientation(LinearLayout.VERTICAL);
593 
594                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
595                 // injecting our custom InputConnection implementation.
596                 final TestEditor testEditor = new TestEditor(activity) {
597                     @Override
598                     public boolean onCheckIsTextEditor() {
599                         return imeSession.isActive();
600                     }
601 
602                     @Override
603                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
604                         if (imeSession.isActive()) {
605                             outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
606                             outAttrs.privateImeOptions = editTextMarker;
607                             return new MyInputConnection();
608                         }
609                         return null;
610                     }
611                 };
612 
613                 layout.addView(testEditor);
614                 removeViewRef.set(() -> layout.removeView(testEditor));
615 
616                 testEditor.requestFocus();
617                 return layout;
618             });
619 
620             // "onStartInput" gets called for the EditText.
621             expectEvent(stream, editorMatcher("onStartInput", editTextMarker), TIMEOUT);
622 
623             runOnMainSyncWithRethrowing(() -> {
624                 // Trigger layout.removeView(testEditor)
625                 removeViewRef.getAndSet(null).run();
626 
627                 // In this state, editText2 is scheduled to become the next IME focus target, but
628                 // it's not yet completed until the next on-idle.
629                 // IMEs' calling IMS#requestCursorUpdates() in this state should not **immediately**
630                 // trigger startInput().
631                 imeSession.callRequestCursorUpdates(0);
632 
633                 // Then issue fence command to verify that IC still receives commands.
634                 imeSession.callPerformPrivateCommand(fenceMarker, null);
635                 try {
636                     assertTrue(fenceCommandLatch.await(TIMEOUT, TimeUnit.MILLISECONDS));
637                 } catch (InterruptedException e) {
638                     throw new RuntimeException(e);
639                 }
640                 assertEquals(1, closeConnectionLatch.getCount());
641             });
642         }
643     }
644 
645     /**
646      * A holder of {@link Handler} that is bound to a background {@link Looper} where
647      * {@link Throwable} thrown from tasks running there will be just ignored instead of triggering
648      * process crashes.
649      */
650     private static final class ErrorSwallowingHandlerThread implements AutoCloseable {
651         @NonNull
652         private final Handler mHandler;
653 
654         @NonNull
getHandler()655         Handler getHandler() {
656             return mHandler;
657         }
658 
659         @NonNull
create()660         static ErrorSwallowingHandlerThread create() {
661             final CountDownLatch latch = new CountDownLatch(1);
662             final AtomicReference<Looper> mLooperRef = new AtomicReference<>();
663             new Thread(() -> {
664                 Looper.prepare();
665                 mLooperRef.set(Looper.myLooper());
666                 latch.countDown();
667 
668                 while (true) {
669                     try {
670                         Looper.loop();
671                         return;
672                     } catch (Throwable ignore) {
673                     }
674                 }
675             }).start();
676 
677             try {
678                 assertTrue(latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
679             } catch (InterruptedException e) {
680                 Thread.currentThread().interrupt();
681                 fail("Failed to create a Handler thread");
682             }
683 
684             final Handler handler = Handler.createAsync(mLooperRef.get());
685             return new ErrorSwallowingHandlerThread(handler);
686         }
687 
ErrorSwallowingHandlerThread(@onNull Handler handler)688         private ErrorSwallowingHandlerThread(@NonNull Handler handler) {
689             mHandler = handler;
690         }
691 
692         @Override
close()693         public void close() {
694             mHandler.getLooper().quitSafely();
695             try {
696                 mHandler.getLooper().getThread().join(TIMEOUT);
697             } catch (InterruptedException e) {
698                 fail("Failed to terminate the thread");
699             }
700         }
701     }
702 
703     /**
704      * Ensures that {@code event}'s elapse time is less than the given threshold.
705      *
706      * @param event {@link ImeEvent} to be tested.
707      * @param elapseThresholdInMilliSecond threshold in milli sec.
708      */
expectElapseTimeLessThan(@onNull ImeEvent event, long elapseThresholdInMilliSecond)709     private static void expectElapseTimeLessThan(@NonNull ImeEvent event,
710             long elapseThresholdInMilliSecond) {
711         final long elapseTimeInMilli = TimeUnit.NANOSECONDS.toMillis(
712                 event.getExitTimestamp() - event.getEnterTimestamp());
713         if (elapseTimeInMilli > elapseThresholdInMilliSecond) {
714             fail(event.getEventName() + " took " + elapseTimeInMilli + " msec,"
715                     + " which must be less than " + elapseThresholdInMilliSecond + " msec.");
716         }
717     }
718 
719     /**
720      * Test {@link InputConnection#getSurroundingText(int, int, int)} that throws an exception.
721      */
722     @Test
testExceptionFromGetSurroundingText()723     public void testExceptionFromGetSurroundingText() throws Exception {
724         try (ErrorSwallowingHandlerThread handlerThread = ErrorSwallowingHandlerThread.create();
725              MockImeSession imeSession = MockImeSession.create(
726                      InstrumentationRegistry.getInstrumentation().getContext(),
727                      InstrumentationRegistry.getInstrumentation().getUiAutomation(),
728                      new ImeSettings.Builder())) {
729 
730             final CountDownLatch latch = new CountDownLatch(1);
731 
732             final class MyInputConnection extends HandlerInputConnection {
733                 MyInputConnection() {
734                     super(handlerThread.getHandler());
735                 }
736 
737                 @Override
738                 public SurroundingText getSurroundingText(int beforeLength, int afterLength,
739                         int flags) {
740                     latch.countDown();
741                     throw new RuntimeException("Exception!");
742                 }
743 
744             }
745 
746             final ImeEventStream stream = imeSession.openEventStream();
747 
748             final String marker = getTestMarker();
749 
750             TestActivity.startSync(activity -> {
751                 final LinearLayout layout = new LinearLayout(activity);
752                 layout.setOrientation(LinearLayout.VERTICAL);
753 
754                 // Just to be conservative, we explicitly check MockImeSession#isActive() here when
755                 // injecting our custom InputConnection implementation.
756                 final TestEditor testEditor = new TestEditor(activity) {
757                     @Override
758                     public boolean onCheckIsTextEditor() {
759                         return imeSession.isActive();
760                     }
761 
762                     @Override
763                     public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
764                         if (imeSession.isActive()) {
765                             outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
766                             outAttrs.privateImeOptions = marker;
767                             return new MyInputConnection();
768                         }
769                         return null;
770                     }
771                 };
772 
773                 testEditor.requestFocus();
774                 layout.addView(testEditor);
775                 return layout;
776             });
777 
778             // Wait until the MockIme gets bound to the TestActivity.
779             expectBindInput(stream, Process.myPid(), TIMEOUT);
780 
781             // Wait until "onStartInput" gets called for the EditText.
782             expectEvent(stream, editorMatcher("onStartInput", marker), TIMEOUT);
783 
784             final ImeCommand command = imeSession.callGetSurroundingText(1, 1, 0);
785             final ImeEvent result = expectCommand(stream, command, TIMEOUT);
786 
787             assertTrue("IC#getSurroundingText() must be called",
788                     latch.await(TIMEOUT, TimeUnit.MILLISECONDS));
789 
790             assertTrue("Exceptions from IC#getSurroundingText() must be interpreted as null.",
791                     result.isNullReturnValue());
792             expectElapseTimeLessThan(result, TIMEOUT_IN_REMOTE_INPUT_CONNECTION);
793         }
794     }
795 }
796