1 /*
<lambda>null2  * Copyright (C) 2020 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 package android.view.inputmethod.cts
17 
18 import android.app.Instrumentation
19 import android.app.UiAutomation
20 import android.content.Context
21 import android.os.Bundle
22 import android.os.Looper
23 import android.provider.Settings
24 import android.text.style.SuggestionSpan
25 import android.text.style.SuggestionSpan.FLAG_GRAMMAR_ERROR
26 import android.text.style.SuggestionSpan.FLAG_MISSPELLED
27 import android.text.style.SuggestionSpan.SUGGESTIONS_MAX_SIZE
28 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
29 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
30 import android.view.inputmethod.InputMethodInfo
31 import android.view.inputmethod.InputMethodManager
32 import android.view.inputmethod.cts.util.EndToEndImeTestBase
33 import android.view.inputmethod.cts.util.InputMethodVisibilityVerifier
34 import android.view.inputmethod.cts.util.TestActivity
35 import android.view.inputmethod.cts.util.TestUtils.runOnMainSync
36 import android.view.inputmethod.cts.util.TestUtils.waitOnMainUntil
37 import android.view.inputmethod.cts.util.UnlockScreenRule
38 import android.view.textservice.SentenceSuggestionsInfo
39 import android.view.textservice.SpellCheckerSession
40 import android.view.textservice.SpellCheckerSubtype
41 import android.view.textservice.SuggestionsInfo
42 import android.view.textservice.SuggestionsInfo.RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS
43 import android.view.textservice.SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY
44 import android.view.textservice.SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR
45 import android.view.textservice.SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO
46 import android.view.textservice.TextInfo
47 import android.view.textservice.TextServicesManager
48 import android.widget.EditText
49 import android.widget.LinearLayout
50 import androidx.annotation.UiThread
51 import androidx.test.filters.MediumTest
52 import androidx.test.platform.app.InstrumentationRegistry
53 import androidx.test.runner.AndroidJUnit4
54 import androidx.test.uiautomator.By
55 import androidx.test.uiautomator.UiDevice
56 import androidx.test.uiautomator.Until
57 import com.android.compatibility.common.util.CtsTouchUtils
58 import com.android.compatibility.common.util.PollingCheck
59 import com.android.compatibility.common.util.SettingsStateChangerRule
60 import com.android.compatibility.common.util.SystemUtil
61 import com.android.cts.mockime.ImeEventStreamTestUtils.expectCommand
62 import com.android.cts.mockime.MockImeSession
63 import com.android.cts.mockspellchecker.EXTRAS_KEY_PREFIX
64 import com.android.cts.mockspellchecker.MockSpellChecker
65 import com.android.cts.mockspellchecker.MockSpellCheckerClient
66 import com.android.cts.mockspellchecker.MockSpellCheckerProto
67 import com.android.cts.mockspellchecker.MockSpellCheckerProto.MockSpellCheckerConfiguration
68 import com.google.common.truth.Truth.assertThat
69 import org.junit.Assert.assertThrows
70 import org.junit.Assert.fail
71 import org.junit.Assume
72 import org.junit.Before
73 import org.junit.Rule
74 import org.junit.Test
75 import org.junit.runner.RunWith
76 import java.lang.IllegalArgumentException
77 import java.util.Locale
78 import java.util.concurrent.Executor
79 import java.util.concurrent.TimeUnit
80 import java.util.concurrent.TimeoutException
81 import kotlin.collections.ArrayList
82 
83 @MediumTest
84 @RunWith(AndroidJUnit4::class)
85 class SpellCheckerTest : EndToEndImeTestBase() {
86 
87     private val TAG = "SpellCheckerTest"
88     private val SPELL_CHECKING_IME_ID = "com.android.cts.spellcheckingime/.SpellCheckingIme"
89     private val TIMEOUT = TimeUnit.SECONDS.toMillis(5)
90 
91     private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
92     private val context: Context = instrumentation.getTargetContext()
93     private val uiDevice: UiDevice = UiDevice.getInstance(instrumentation)
94     private val uiAutomation: UiAutomation = instrumentation.uiAutomation
95 
96     @Rule
97     fun unlockScreenRule() = UnlockScreenRule()
98 
99     @Rule
100     fun spellCheckerSettingsRule() = SettingsStateChangerRule(
101             context, Settings.Secure.SELECTED_SPELL_CHECKER, MockSpellChecker.getId())
102 
103     @Rule
104     fun spellCheckerSubtypeSettingsRule() = SettingsStateChangerRule(
105             context, Settings.Secure.SELECTED_SPELL_CHECKER_SUBTYPE,
106             SpellCheckerSubtype.SUBTYPE_ID_NONE.toString())
107 
108     @Before
109     fun setUp() {
110         val tsm = context.getSystemService(TextServicesManager::class.java)!!
111         // Skip if spell checker is not enabled by default.
112         Assume.assumeNotNull(tsm)
113         Assume.assumeTrue(tsm.isSpellCheckerEnabled)
114     }
115 
116     @Test
117     fun misspelled_easyCorrect() {
118         val uniqueSuggestion = "s618397" // "s" + a random number
119         val configuration = MockSpellCheckerConfiguration.newBuilder()
120                 .addSuggestionRules(
121                         MockSpellCheckerProto.SuggestionRule.newBuilder()
122                                 .setMatch("match")
123                                 .addSuggestions(uniqueSuggestion)
124                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
125                 ).build()
126         MockImeSession.create(context).use { session ->
127             MockSpellCheckerClient.create(context, configuration).use {
128                 val (_, editText) = startTestActivity()
129                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
130                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
131                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
132                 session.callCommitText("match", 1)
133                 session.callCommitText(" ", 1)
134                 waitOnMainUntil({
135                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) != null
136                 }, TIMEOUT)
137                 // Tap inside 'match'.
138                 emulateTapAtOffset(editText, 2)
139                 // Wait until the cursor moves inside 'match'.
140                 waitOnMainUntil({ isCursorInside(editText, 1, 4) }, TIMEOUT)
141                 // Wait for the suggestion to come up, and click it.
142                 uiDevice.wait(Until.findObject(By.text(uniqueSuggestion)), TIMEOUT).also {
143                     assertThat(it).isNotNull()
144                 }.click()
145                 // Verify that the text ('match') is replaced with the suggestion.
146                 waitOnMainUntil({ "$uniqueSuggestion " == editText.text.toString() }, TIMEOUT)
147                 // The SuggestionSpan should be removed.
148                 waitOnMainUntil({
149                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) == null
150                 }, TIMEOUT)
151             }
152         }
153     }
154 
155     @Test
156     fun misspelled_noEasyCorrect() {
157         val uniqueSuggestion = "s974355" // "s" + a random number
158         val configuration = MockSpellCheckerConfiguration.newBuilder()
159                 .addSuggestionRules(
160                         MockSpellCheckerProto.SuggestionRule.newBuilder()
161                                 .setMatch("match")
162                                 .addSuggestions(uniqueSuggestion)
163                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO
164                                         or RESULT_ATTR_DONT_SHOW_UI_FOR_SUGGESTIONS)
165                 ).build()
166         MockImeSession.create(context).use { session ->
167             MockSpellCheckerClient.create(context, configuration).use {
168                 val (_, editText) = startTestActivity()
169                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
170                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
171                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
172                 session.callCommitText("match", 1)
173                 session.callCommitText(" ", 1)
174                 waitOnMainUntil({
175                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) != null
176                 }, TIMEOUT)
177                 // Tap inside 'match'.
178                 emulateTapAtOffset(editText, 2)
179                 // Wait until the cursor moves inside 'match'.
180                 waitOnMainUntil({ isCursorInside(editText, 1, 4) }, TIMEOUT)
181                 // Verify that the suggestion is not shown.
182                 assertThat(uiDevice.wait(Until.gone(By.text(uniqueSuggestion)), TIMEOUT)).isTrue()
183             }
184         }
185     }
186 
187     @Test
188     fun grammarError() {
189         val configuration = MockSpellCheckerConfiguration.newBuilder()
190                 .addSuggestionRules(
191                         MockSpellCheckerProto.SuggestionRule.newBuilder()
192                                 .setMatch("match")
193                                 .addSuggestions("suggestion")
194                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR)
195         ).build()
196         MockImeSession.create(context).use { session ->
197             MockSpellCheckerClient.create(context, configuration).use {
198                 val (_, editText) = startTestActivity()
199                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
200                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
201                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
202                 session.callCommitText("match", 1)
203                 session.callCommitText(" ", 1)
204                 waitOnMainUntil({
205                     findSuggestionSpanWithFlags(editText, FLAG_GRAMMAR_ERROR) != null
206                 }, TIMEOUT)
207             }
208         }
209     }
210 
211     @Test
212     fun performSpellCheck() {
213         val configuration = MockSpellCheckerConfiguration.newBuilder()
214                 .addSuggestionRules(
215                         MockSpellCheckerProto.SuggestionRule.newBuilder()
216                                 .setMatch("match")
217                                 .addSuggestions("suggestion")
218                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
219                 ).build()
220         MockImeSession.create(context).use { session ->
221             MockSpellCheckerClient.create(context, configuration).use { client ->
222                 val stream = session.openEventStream()
223                 val (_, editText) = startTestActivity()
224                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
225                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
226                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
227                 session.callCommitText("match", 1)
228                 session.callCommitText(" ", 1)
229                 waitOnMainUntil({
230                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) != null
231                 }, TIMEOUT)
232                 // The word is now in dictionary. The next spell check should remove the misspelled
233                 // SuggestionSpan.
234                 client.updateConfiguration(MockSpellCheckerConfiguration.newBuilder()
235                         .addSuggestionRules(
236                                 MockSpellCheckerProto.SuggestionRule.newBuilder()
237                                         .setMatch("match")
238                                         .setAttributes(RESULT_ATTR_IN_THE_DICTIONARY)
239                         ).build())
240                 val command = session.callPerformSpellCheck()
241                 expectCommand(stream, command, TIMEOUT)
242                 waitOnMainUntil({
243                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) == null
244                 }, TIMEOUT)
245             }
246         }
247     }
248 
249     @Test
250     fun textServicesManagerApi() {
251         val tsm = context.getSystemService(TextServicesManager::class.java)!!
252         assertThat(tsm).isNotNull()
253         assertThat(tsm!!.isSpellCheckerEnabled()).isTrue()
254         val spellCheckerInfo = tsm.getCurrentSpellCheckerInfo()
255         assertThat(spellCheckerInfo).isNotNull()
256         assertThat(spellCheckerInfo!!.getPackageName()).isEqualTo(
257                 "com.android.cts.mockspellchecker")
258         assertThat(spellCheckerInfo!!.getSubtypeCount()).isEqualTo(1)
259         assertThat(tsm.getEnabledSpellCheckerInfos()!!.size).isAtLeast(1)
260         assertThat(tsm.getEnabledSpellCheckerInfos()!!.map { it.getPackageName() })
261                 .contains("com.android.cts.mockspellchecker")
262     }
263 
264     @Test
265     fun newSpellCheckerSession() {
266         val configuration = MockSpellCheckerConfiguration.newBuilder()
267                 .addSuggestionRules(
268                         MockSpellCheckerProto.SuggestionRule.newBuilder()
269                                 .setMatch("match")
270                                 .addSuggestions("suggestion")
271                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
272                 ).build()
273         MockSpellCheckerClient.create(context, configuration).use {
274             val tsm = context.getSystemService(TextServicesManager::class.java)
275             assertThat(tsm).isNotNull()
276             val fakeListener = FakeSpellCheckerSessionListener()
277             val fakeExecutor = FakeExecutor()
278             val params = SpellCheckerSession.SpellCheckerSessionParams.Builder()
279                     .setLocale(Locale.US)
280                     .setSupportedAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
281                     .build()
282             val session: SpellCheckerSession? = tsm?.newSpellCheckerSession(
283                     params, fakeExecutor, fakeListener)
284             assertThat(session).isNotNull()
285             session?.getSentenceSuggestions(arrayOf(TextInfo("match")), 5)
286             waitOnMainUntil({ fakeExecutor.runnables.size == 1 }, TIMEOUT)
287             fakeExecutor.runnables[0].run()
288 
289             assertThat(fakeListener.getSentenceSuggestionsResults).hasSize(1)
290             assertThat(fakeListener.getSentenceSuggestionsResults[0]).hasLength(1)
291             val sentenceSuggestionsInfo = fakeListener.getSentenceSuggestionsResults[0]!![0]
292             assertThat(sentenceSuggestionsInfo.suggestionsCount).isEqualTo(1)
293             assertThat(sentenceSuggestionsInfo.getOffsetAt(0)).isEqualTo(0)
294             assertThat(sentenceSuggestionsInfo.getLengthAt(0)).isEqualTo("match".length)
295             val suggestionsInfo = sentenceSuggestionsInfo.getSuggestionsInfoAt(0)
296             assertThat(suggestionsInfo.suggestionsCount).isEqualTo(1)
297             assertThat(suggestionsInfo.getSuggestionAt(0)).isEqualTo("suggestion")
298 
299             assertThat(fakeListener.getSentenceSuggestionsResults).hasSize(1)
300             assertThat(fakeListener.getSentenceSuggestionsCallingThreads).hasSize(1)
301             assertThat(fakeListener.getSentenceSuggestionsCallingThreads[0])
302                     .isEqualTo(Thread.currentThread())
303         }
304     }
305 
306     @Test
307     fun newSpellCheckerSession_implicitExecutor() {
308         val configuration = MockSpellCheckerConfiguration.newBuilder()
309                 .addSuggestionRules(
310                         MockSpellCheckerProto.SuggestionRule.newBuilder()
311                                 .setMatch("match")
312                                 .addSuggestions("suggestion")
313                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
314                 ).build()
315         MockSpellCheckerClient.create(context, configuration).use {
316             val tsm = context.getSystemService(TextServicesManager::class.java)
317             assertThat(tsm).isNotNull()
318             val fakeListener = FakeSpellCheckerSessionListener()
319             var session: SpellCheckerSession? = null
320             runOnMainSync {
321                 session = tsm?.newSpellCheckerSession(null /* bundle */, Locale.US,
322                         fakeListener, false /* referToSpellCheckerLanguageSettings */)
323             }
324             assertThat(session).isNotNull()
325             session?.getSentenceSuggestions(arrayOf(TextInfo("match")), 5)
326             waitOnMainUntil({
327                 fakeListener.getSentenceSuggestionsCallingThreads.size > 0
328             }, TIMEOUT)
329             runOnMainSync {
330                 assertThat(fakeListener.getSentenceSuggestionsCallingThreads).hasSize(1)
331                 assertThat(fakeListener.getSentenceSuggestionsCallingThreads[0])
332                         .isEqualTo(Looper.getMainLooper().thread)
333             }
334         }
335     }
336 
337     @Test
338     fun newSpellCheckerSession_extras() {
339         val configuration = MockSpellCheckerConfiguration.newBuilder()
340                 .addSuggestionRules(
341                         MockSpellCheckerProto.SuggestionRule.newBuilder()
342                                 .setMatch("match")
343                                 .addSuggestions("suggestion")
344                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
345                 ).build()
346         MockSpellCheckerClient.create(context, configuration).use {
347             val tsm = context.getSystemService(TextServicesManager::class.java)
348             assertThat(tsm).isNotNull()
349             val fakeListener = FakeSpellCheckerSessionListener()
350             val fakeExecutor = FakeExecutor()
351             // Set a prefix. MockSpellChecker will add "test_" to the spell check result.
352             val extras = Bundle()
353             extras.putString(EXTRAS_KEY_PREFIX, "test_")
354             val params = SpellCheckerSession.SpellCheckerSessionParams.Builder()
355                     .setLocale(Locale.US)
356                     .setSupportedAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
357                     .setExtras(extras)
358                     .build()
359             val session: SpellCheckerSession? = tsm?.newSpellCheckerSession(
360                     params, fakeExecutor, fakeListener)
361             assertThat(session).isNotNull()
362             session?.getSentenceSuggestions(arrayOf(TextInfo("match")), 5)
363             waitOnMainUntil({ fakeExecutor.runnables.size == 1 }, TIMEOUT)
364             fakeExecutor.runnables[0].run()
365 
366             assertThat(fakeListener.getSentenceSuggestionsResults).hasSize(1)
367             assertThat(fakeListener.getSentenceSuggestionsResults[0]).hasLength(1)
368             val sentenceSuggestionsInfo = fakeListener.getSentenceSuggestionsResults[0]!![0]
369             assertThat(sentenceSuggestionsInfo.suggestionsCount).isEqualTo(1)
370             val suggestionsInfo = sentenceSuggestionsInfo.getSuggestionsInfoAt(0)
371             assertThat(suggestionsInfo.suggestionsCount).isEqualTo(1)
372             assertThat(suggestionsInfo.getSuggestionAt(0)).isEqualTo("test_suggestion")
373         }
374     }
375 
376     @Test
377     fun spellCheckerSessionParamsBuilder() {
378         // Locale or shouldReferToSpellCheckerLanguageSettings should be set.
379         assertThrows(IllegalArgumentException::class.java) {
380             SpellCheckerSession.SpellCheckerSessionParams.Builder().build()
381         }
382 
383         // Test defaults.
384         val localeOnly = SpellCheckerSession.SpellCheckerSessionParams.Builder()
385                 .setLocale(Locale.US)
386                 .build()
387         assertThat(localeOnly.locale).isEqualTo(Locale.US)
388         assertThat(localeOnly.shouldReferToSpellCheckerLanguageSettings()).isFalse()
389         assertThat(localeOnly.supportedAttributes).isEqualTo(0)
390         assertThat(localeOnly.extras).isNotNull()
391         assertThat(localeOnly.extras.size()).isEqualTo(0)
392 
393         // Test setters.
394         val extras = Bundle()
395         extras.putString("key", "value")
396         val params = SpellCheckerSession.SpellCheckerSessionParams.Builder()
397                 .setLocale(Locale.CANADA)
398                 .setShouldReferToSpellCheckerLanguageSettings(true)
399                 .setSupportedAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
400                 .setExtras(extras)
401                 .build()
402         assertThat(params.locale).isEqualTo(Locale.CANADA)
403         assertThat(params.shouldReferToSpellCheckerLanguageSettings()).isTrue()
404         assertThat(params.supportedAttributes).isEqualTo(RESULT_ATTR_LOOKS_LIKE_TYPO)
405         // Bundle does not implement equals.
406         assertThat(params.extras).isNotNull()
407         assertThat(params.extras.size()).isEqualTo(1)
408         assertThat(params.extras.getString("key")).isEqualTo("value")
409     }
410 
411     @Test
412     fun suppressesSpellChecker() {
413         val configuration = MockSpellCheckerConfiguration.newBuilder()
414                 .addSuggestionRules(
415                         MockSpellCheckerProto.SuggestionRule.newBuilder()
416                                 .setMatch("match")
417                                 .addSuggestions("suggestion")
418                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
419                 ).build()
420         // SpellCheckingIme should have android:suppressesSpellChecker="true"
421         ImeSession(SPELL_CHECKING_IME_ID).use {
422             assertThat(getCurrentInputMethodInfo().suppressesSpellChecker()).isTrue()
423 
424             MockSpellCheckerClient.create(context, configuration).use {
425                 val (activity, editText) = startTestActivity()
426                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
427                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
428                 val imm = activity.getSystemService(InputMethodManager::class.java)
429                 assertThat(imm?.isInputMethodSuppressingSpellChecker).isTrue()
430 
431                 // SpellCheckerSession should return empty results if suppressed.
432                 val tsm = activity.getSystemService(TextServicesManager::class.java)
433                 val listener = FakeSpellCheckerSessionListener()
434                 var session: SpellCheckerSession? = null
435                 runOnMainSync {
436                     session = tsm?.newSpellCheckerSession(null, Locale.US, listener, false)
437                 }
438                 assertThat(session).isNotNull()
439                 val suggestions: Array<SentenceSuggestionsInfo>? =
440                         getSentenceSuggestions(session!!, listener, "match")
441                 assertThat(suggestions).isNotNull()
442                 assertThat(suggestions!!.size).isEqualTo(0)
443             }
444         }
445     }
446 
447     @Test
448     fun suppressesSpellChecker_false() {
449         MockImeSession.create(context).use {
450             assertThat(getCurrentInputMethodInfo().suppressesSpellChecker()).isFalse()
451 
452             val (activity, editText) = startTestActivity()
453             CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
454             waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
455             val imm = activity.getSystemService(InputMethodManager::class.java)
456             assertThat(imm?.isInputMethodSuppressingSpellChecker).isFalse()
457         }
458     }
459 
460     @Test
461     fun trailingPunctuation() {
462         // Set up a rule that matches the sentence "match?" and marks it as grammar error.
463         val configuration = MockSpellCheckerConfiguration.newBuilder()
464                 .setMatchSentence(true)
465                 .addSuggestionRules(
466                         MockSpellCheckerProto.SuggestionRule.newBuilder()
467                                 .setMatch("match?")
468                                 .addSuggestions("suggestion.")
469                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR)
470                 ).build()
471         MockImeSession.create(context).use { session ->
472             MockSpellCheckerClient.create(context, configuration).use { client ->
473                 val (_, editText) = startTestActivity()
474                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
475                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
476                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
477                 session.callCommitText("match", 1)
478                 // The trailing punctuation "?" is also sent in the next spell check, and the
479                 // sentence "match?" will be marked as FLAG_GRAMMAR_ERROR according to the
480                 // configuration.
481                 session.callCommitText("?", 1)
482                 waitOnMainUntil({
483                     findSuggestionSpanWithFlags(editText, FLAG_GRAMMAR_ERROR) != null
484                 }, TIMEOUT)
485             }
486         }
487     }
488 
489     @Test
490     fun newSpellCheckerSession_processPurePunctuationRequest() {
491         val configuration = MockSpellCheckerConfiguration.newBuilder()
492                 .addSuggestionRules(
493                         MockSpellCheckerProto.SuggestionRule.newBuilder()
494                                 .setMatch("foo")
495                                 .addSuggestions("suggestion")
496                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
497                 ).build()
498         MockSpellCheckerClient.create(context, configuration).use {
499             val tsm = context.getSystemService(TextServicesManager::class.java)
500             assertThat(tsm).isNotNull()
501             val fakeListener = FakeSpellCheckerSessionListener()
502             val fakeExecutor = FakeExecutor()
503             val params = SpellCheckerSession.SpellCheckerSessionParams.Builder()
504                     .setLocale(Locale.US)
505                     .setSupportedAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
506                     .build()
507             var session: SpellCheckerSession? = tsm?.newSpellCheckerSession(
508                     params, fakeExecutor, fakeListener)
509             assertThat(session).isNotNull()
510             session?.getSentenceSuggestions(arrayOf(TextInfo(". ")), 5)
511             waitOnMainUntil({ fakeExecutor.runnables.size == 1 }, TIMEOUT)
512             fakeExecutor.runnables[0].run()
513             assertThat(fakeListener.getSentenceSuggestionsResults).hasSize(1)
514             assertThat(fakeListener.getSentenceSuggestionsResults[0]).hasLength(1)
515             assertThat(fakeListener.getSentenceSuggestionsResults[0]!![0]).isNull()
516         }
517     }
518 
519     @Test
520     fun respectSentenceBoundary() {
521         // Set up two rules:
522         // - Matches the sentence "Preceding text?" and marks it as grammar error.
523         // - Matches the sentence "match?" and marks it as misspelled.
524         val configuration = MockSpellCheckerConfiguration.newBuilder()
525                 .setMatchSentence(true)
526                 .addSuggestionRules(
527                         MockSpellCheckerProto.SuggestionRule.newBuilder()
528                                 .setMatch("Preceding text?")
529                                 .addSuggestions("suggestion.")
530                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR)
531                 ).addSuggestionRules(
532                         MockSpellCheckerProto.SuggestionRule.newBuilder()
533                                 .setMatch("match?")
534                                 .addSuggestions("suggestion.")
535                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
536                 ).build()
537         MockImeSession.create(context).use { session ->
538             MockSpellCheckerClient.create(context, configuration).use { client ->
539                 val (_, editText) = startTestActivity()
540                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
541                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
542                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
543                 session.callCommitText("Preceding text", 1)
544                 session.callCommitText("?", 1)
545                 waitOnMainUntil({
546                     findSuggestionSpanWithFlags(editText, FLAG_GRAMMAR_ERROR) != null
547                 }, TIMEOUT)
548                 // The next spell check only contains the text after "Preceding text?". According
549                 // to our configuration, the sentence "match?" will be marked as FLAG_MISSPELLED.
550                 session.callCommitText("match", 1)
551                 session.callCommitText("?", 1)
552                 waitOnMainUntil({
553                     findSuggestionSpanWithFlags(editText, FLAG_MISSPELLED) != null
554                 }, TIMEOUT)
555             }
556         }
557     }
558 
559     @Test
560     fun removePreviousSuggestion() {
561         // Set up two rules:
562         // - Matches the sentence "Wrong context word?" and marks "word" as grammar error.
563         // - Matches the sentence "Correct context word?" and marks "word" as in-vocabulary.
564         val configuration = MockSpellCheckerConfiguration.newBuilder()
565                 .setMatchSentence(true)
566                 .addSuggestionRules(
567                         MockSpellCheckerProto.SuggestionRule.newBuilder()
568                                 .setMatch("Wrong context word?")
569                                 .addSuggestions("suggestion")
570                                 .setStartOffset(14)
571                                 .setLength(4)
572                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_GRAMMAR_ERROR)
573                 ).addSuggestionRules(
574                         MockSpellCheckerProto.SuggestionRule.newBuilder()
575                                 .setMatch("Correct context word?")
576                                 .setStartOffset(16)
577                                 .setLength(4)
578                                 .setAttributes(RESULT_ATTR_IN_THE_DICTIONARY)
579                 ).build()
580         MockImeSession.create(context).use { session ->
581             MockSpellCheckerClient.create(context, configuration).use { client ->
582                 val (_, editText) = startTestActivity()
583                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
584                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
585                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
586                 session.callCommitText("Wrong context word", 1)
587                 session.callCommitText("?", 1)
588                 waitOnMainUntil({
589                     findSuggestionSpanWithFlags(editText, FLAG_GRAMMAR_ERROR) != null
590                 }, TIMEOUT)
591                 // Change "Wrong" to "Correct" and then trigger spell check.
592                 session.callSetSelection(0, 5) // Select "Wrong"
593                 session.callCommitText("Correct", 1)
594                 session.callPerformSpellCheck()
595                 waitOnMainUntil({
596                     findSuggestionSpanWithFlags(editText, FLAG_GRAMMAR_ERROR) == null
597                 }, TIMEOUT)
598             }
599         }
600     }
601 
602     @Test
603     fun ignoreInvalidSuggestions() {
604         // Set up a wrong rule:
605         // - Matches the sentence "Context word" and marks "word" as grammar error.
606         val configuration = MockSpellCheckerConfiguration.newBuilder()
607                 .setMatchSentence(true)
608                 .addSuggestionRules(
609                         MockSpellCheckerProto.SuggestionRule.newBuilder()
610                                 .setMatch("Context word")
611                                 .addSuggestions("suggestion")
612                                 .setStartOffset(8)
613                                 .setLength(5) // Should be 4
614                                 .setAttributes(RESULT_ATTR_LOOKS_LIKE_TYPO)
615                 ).build()
616         MockImeSession.create(context).use { session ->
617             MockSpellCheckerClient.create(context, configuration).use { client ->
618                 val (_, editText) = startTestActivity()
619                 CtsTouchUtils.emulateTapOnViewCenter(instrumentation, null, editText)
620                 waitOnMainUntil({ editText.hasFocus() }, TIMEOUT)
621                 InputMethodVisibilityVerifier.expectImeVisible(TIMEOUT)
622                 session.callCommitText("Context word", 1)
623                 session.callPerformSpellCheck()
624                 try {
625                     waitOnMainUntil({
626                         findSuggestionSpanWithFlags(editText, RESULT_ATTR_LOOKS_LIKE_TYPO) != null
627                     }, TIMEOUT)
628                     fail("Invalid suggestions should be ignored")
629                 } catch (e: TimeoutException) {
630                     // Expected.
631                 }
632             }
633         }
634     }
635 
636     private fun findSuggestionSpanWithFlags(editText: EditText, flags: Int): SuggestionSpan? =
637             getSuggestionSpans(editText).find { (it.flags and flags) == flags }
638 
639     private fun getSuggestionSpans(editText: EditText): Array<SuggestionSpan> {
640         val editable = editText.text
641         val spans = editable.getSpans(0, editable.length, SuggestionSpan::class.java)
642         return spans
643     }
644 
645     private fun emulateTapAtOffset(editText: EditText, offset: Int) {
646         var x = 0
647         var y = 0
648         runOnMainSync {
649             x = editText.layout.getPrimaryHorizontal(offset).toInt()
650             val line = editText.layout.getLineForOffset(offset)
651             y = (editText.layout.getLineTop(line) + editText.layout.getLineBottom(line)) / 2
652         }
653         CtsTouchUtils.emulateTapOnView(instrumentation, null, editText, x, y)
654     }
655 
656     @UiThread
657     private fun isCursorInside(editText: EditText, start: Int, end: Int): Boolean =
658             start <= editText.selectionStart && editText.selectionEnd <= end
659 
660     private fun startTestActivity(): Pair<TestActivity, EditText> {
661         var editText: EditText? = null
662         val activity = TestActivity.startSync { activity: TestActivity? ->
663             val layout = LinearLayout(activity)
664             editText = EditText(activity)
665             layout.addView(editText, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT))
666             layout
667         }
668         return Pair(activity, editText!!)
669     }
670 
671     private fun getCurrentInputMethodInfo(): InputMethodInfo {
672         val curId = Settings.Secure.getString(context.getContentResolver(),
673                 Settings.Secure.DEFAULT_INPUT_METHOD)
674         val imm = context.getSystemService(InputMethodManager::class.java)
675         val info = imm?.inputMethodList?.find { it.id == curId }
676         assertThat(info).isNotNull()
677         return info!!
678     }
679 
680     private fun getSentenceSuggestions(
681         session: SpellCheckerSession,
682         listener: FakeSpellCheckerSessionListener,
683         text: String
684     ): Array<SentenceSuggestionsInfo>? {
685         val prevSize = listener.getSentenceSuggestionsResults.size
686         session.getSentenceSuggestions(arrayOf(TextInfo(text)), SUGGESTIONS_MAX_SIZE)
687         waitOnMainUntil({
688             listener.getSentenceSuggestionsResults.size == prevSize + 1
689         }, TIMEOUT)
690         return listener.getSentenceSuggestionsResults[prevSize]
691     }
692 
693     private inner class ImeSession(val imeId: String) : AutoCloseable {
694 
695         init {
696             SystemUtil.runCommandAndPrintOnLogcat(TAG, "ime reset")
697             SystemUtil.runCommandAndPrintOnLogcat(TAG, "ime enable $imeId")
698             SystemUtil.runCommandAndPrintOnLogcat(TAG, "ime set $imeId")
699             PollingCheck.check("Make sure that $imeId is selected", TIMEOUT) {
700                 getCurrentInputMethodInfo().id == imeId
701             }
702         }
703 
704         override fun close() {
705             SystemUtil.runCommandAndPrintOnLogcat(TAG, "ime reset")
706         }
707     }
708 
709     private class FakeSpellCheckerSessionListener :
710             SpellCheckerSession.SpellCheckerSessionListener {
711         val getSentenceSuggestionsResults = ArrayList<Array<SentenceSuggestionsInfo>?>()
712         val getSentenceSuggestionsCallingThreads = ArrayList<Thread>()
713 
714         override fun onGetSuggestions(results: Array<SuggestionsInfo>?) {
715             fail("Not expected")
716         }
717 
718         override fun onGetSentenceSuggestions(results: Array<SentenceSuggestionsInfo>?) {
719             getSentenceSuggestionsResults.add(results)
720             getSentenceSuggestionsCallingThreads.add(Thread.currentThread())
721         }
722     }
723 
724     private class FakeExecutor : Executor {
725         @get:Synchronized
726         val runnables = ArrayList<Runnable>()
727 
728         @Synchronized
729         override fun execute(r: Runnable) {
730             runnables.add(r)
731         }
732     }
733 }
734