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