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