blob: 2272a757832907169f352c8f91ab9620a6614982 [file] [log] [blame]
Zach Klippenstein3f9688c2022-01-16 18:12:45 -08001/*
2 * Copyright 2022 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
17package androidx.compose.ui.text.input
18
19import android.os.IBinder
20import android.view.View
21import android.view.inputmethod.ExtractedText
22import com.google.common.truth.Truth.assertThat
23import com.nhaarman.mockitokotlin2.mock
24import com.nhaarman.mockitokotlin2.whenever
25import kotlinx.coroutines.ExperimentalCoroutinesApi
26import kotlinx.coroutines.Job
27import kotlinx.coroutines.cancel
28import kotlinx.coroutines.launch
29import kotlinx.coroutines.test.TestCoroutineScope
30import org.junit.After
31import org.junit.Before
32import org.junit.Test
33
34@OptIn(ExperimentalCoroutinesApi::class)
35class TextInputServiceAndroidCommandDebouncingTest {
36
37 private val view = mock<View>()
38 private val inputMethodManager = TestInputMethodManager()
39 private val service = TextInputServiceAndroid(view, inputMethodManager)
40 private val scope = TestCoroutineScope(Job())
41
42 @Before
43 fun setUp() {
44 // Default the view to focused because when it's not focused commands should be ignored.
45 whenever(view.isFocused).thenReturn(true)
46
47 // Pause the dispatcher so that tests can send multiple commands before they get processed
48 // by the command event loop. This simulates how events are processed when multiple back-to-
49 // back focus events send commands to the service before the coroutine resumes on the next
50 // main loop iteration.
51 scope.pauseDispatcher()
52 scope.launch { service.textInputCommandEventLoop() }
53 }
54
55 @After
56 fun tearDown() {
57 scope.cancel()
58 }
59
60 @Test
61 fun showKeyboard_callsShowKeyboard() {
62 service.showSoftwareKeyboard()
63 scope.advanceUntilIdle()
64
65 assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
66 assertThat(inputMethodManager.restartCalls).isEmpty()
67 assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
68 }
69
70 @Test
71 fun hideKeyboard_callsHideKeyboard() {
72 service.hideSoftwareKeyboard()
73 scope.advanceUntilIdle()
74
75 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
76 assertThat(inputMethodManager.restartCalls).isEmpty()
77 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
78 }
79
80 @Test
81 fun startInput_callsRestartInput() {
82 service.startInput()
83 scope.advanceUntilIdle()
84
85 assertThat(inputMethodManager.restartCalls).hasSize(1)
86 }
87
88 @Test
89 fun startInput_callsShowKeyboard() {
90 service.startInput()
91 scope.advanceUntilIdle()
92
93 assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
94 }
95
96 @Test
97 fun stopInput_callsRestartInput() {
98 service.stopInput()
99 scope.advanceUntilIdle()
100
101 assertThat(inputMethodManager.restartCalls).hasSize(1)
102 }
103
104 @Test
105 fun stopInput_callsHideKeyboard() {
106 service.stopInput()
107 scope.advanceUntilIdle()
108
109 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
110 }
111
112 @Test
113 fun startThenStopInput_onlyCallsRestartOnce() {
114 service.startInput()
115 service.stopInput()
116 scope.advanceUntilIdle()
117
118 // Both startInput and stopInput restart the IMM. So calling those two methods back-to-back,
119 // in either order, should debounce to a single restart call. If they aren't de-duped, the
120 // keyboard may flicker if one of the calls configures the IME in a non-default way (e.g.
121 // number input).
122 assertThat(inputMethodManager.restartCalls).hasSize(1)
123 }
124
125 @Test
126 fun stopThenStartInput_onlyCallsRestartOnce() {
127 service.stopInput()
128 service.startInput()
129 scope.advanceUntilIdle()
130
131 // Both startInput and stopInput restart the IMM. So calling those two methods back-to-back,
132 // in either order, should debounce to a single restart call. If they aren't de-duped, the
133 // keyboard may flicker if one of the calls configures the IME in a non-default way (e.g.
134 // number input).
135 assertThat(inputMethodManager.restartCalls).hasSize(1)
136 }
137
138 @Test
139 fun showKeyboard_afterStopInput_isIgnored() {
140 service.stopInput()
141 service.showSoftwareKeyboard()
142 scope.advanceUntilIdle()
143
144 // After stopInput, there's no input connection, so any calls to show the keyboard should
145 // be ignored until the next call to startInput.
146 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
147 }
148
149 @Test
150 fun hideKeyboard_afterStopInput_isIgnored() {
151 service.stopInput()
152 service.hideSoftwareKeyboard()
153 scope.advanceUntilIdle()
154
155 // stopInput will hide the keyboard implicitly, so both stopInput and hideSoftwareKeyboard
156 // have the effect "hide the keyboard". These two effects should be debounced and the IMM
157 // should only get a single hide call instead of two redundant calls.
158 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
159 }
160
161 @Test
162 fun multipleShowCallsAreDebounced() {
163 repeat(10) {
164 service.showSoftwareKeyboard()
165 }
166 scope.advanceUntilIdle()
167
168 assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
169 }
170
171 @Test
172 fun multipleHideCallsAreDebounced() {
173 repeat(10) {
174 service.hideSoftwareKeyboard()
175 }
176 scope.advanceUntilIdle()
177
178 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
179 }
180
181 @Test
182 fun showThenHideAreDebounced() {
183 service.showSoftwareKeyboard()
184 service.hideSoftwareKeyboard()
185 scope.advanceUntilIdle()
186
187 assertThat(inputMethodManager.showSoftInputCalls).hasSize(0)
188 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
189 }
190
191 @Test
192 fun hideThenShowAreDebounced() {
193 service.hideSoftwareKeyboard()
194 service.showSoftwareKeyboard()
195 scope.advanceUntilIdle()
196
197 assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
198 assertThat(inputMethodManager.hideSoftInputCalls).hasSize(0)
199 }
200
201 @Test fun stopInput_isNotProcessedImmediately() {
202 service.stopInput()
203
204 assertThat(inputMethodManager.restartCalls).isEmpty()
205 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
206 assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
207 }
208
209 @Test fun startInput_isNotProcessedImmediately() {
210 service.startInput()
211
212 assertThat(inputMethodManager.restartCalls).isEmpty()
213 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
214 assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
215 }
216
217 @Test fun showSoftwareKeyboard_isNotProcessedImmediately() {
218 service.showSoftwareKeyboard()
219
220 assertThat(inputMethodManager.restartCalls).isEmpty()
221 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
222 assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
223 }
224
225 @Test fun hideSoftwareKeyboard_isNotProcessedImmediately() {
226 service.hideSoftwareKeyboard()
227
228 assertThat(inputMethodManager.restartCalls).isEmpty()
229 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
230 assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
231 }
232
233 @Test fun commandsAreIgnored_ifFocusLostBeforeProcessing() {
234 // Send command while view still has focus.
235 service.showSoftwareKeyboard()
236 // Blur the view.
237 whenever(view.isFocused).thenReturn(false)
238 // Process the queued commands.
239 scope.advanceUntilIdle()
240
241 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
242 }
243
244 @Test fun commandsAreDrained_whenProcessedWithoutFocus() {
245 whenever(view.isFocused).thenReturn(false)
246 service.showSoftwareKeyboard()
247 service.hideSoftwareKeyboard()
248 scope.advanceUntilIdle()
249 whenever(view.isFocused).thenReturn(true)
250 scope.advanceUntilIdle()
251
252 assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
253 }
254
255 private fun TextInputServiceAndroid.startInput() {
256 startInput(
257 TextFieldValue(),
258 ImeOptions.Default,
259 onEditCommand = {},
260 onImeActionPerformed = {}
261 )
262 }
263
264 private class TestInputMethodManager : InputMethodManager {
265 val restartCalls = mutableListOf<View>()
266 val showSoftInputCalls = mutableListOf<View>()
267 val hideSoftInputCalls = mutableListOf<IBinder?>()
268
269 override fun restartInput(view: View) {
270 restartCalls += view
271 }
272
273 override fun showSoftInput(view: View) {
274 showSoftInputCalls += view
275 }
276
277 override fun hideSoftInputFromWindow(windowToken: IBinder?) {
278 hideSoftInputCalls += windowToken
279 }
280
281 override fun updateExtractedText(view: View, token: Int, extractedText: ExtractedText) {
282 }
283
284 override fun updateSelection(
285 view: View,
286 selectionStart: Int,
287 selectionEnd: Int,
288 compositionStart: Int,
289 compositionEnd: Int
290 ) {
291 }
292 }
293}