blob: 2272a757832907169f352c8f91ab9620a6614982 [file] [log] [blame]
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.text.input
import android.os.IBinder
import android.view.View
import android.view.inputmethod.ExtractedText
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineScope
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TextInputServiceAndroidCommandDebouncingTest {
private val view = mock<View>()
private val inputMethodManager = TestInputMethodManager()
private val service = TextInputServiceAndroid(view, inputMethodManager)
private val scope = TestCoroutineScope(Job())
@Before
fun setUp() {
// Default the view to focused because when it's not focused commands should be ignored.
whenever(view.isFocused).thenReturn(true)
// Pause the dispatcher so that tests can send multiple commands before they get processed
// by the command event loop. This simulates how events are processed when multiple back-to-
// back focus events send commands to the service before the coroutine resumes on the next
// main loop iteration.
scope.pauseDispatcher()
scope.launch { service.textInputCommandEventLoop() }
}
@After
fun tearDown() {
scope.cancel()
}
@Test
fun showKeyboard_callsShowKeyboard() {
service.showSoftwareKeyboard()
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
}
@Test
fun hideKeyboard_callsHideKeyboard() {
service.hideSoftwareKeyboard()
scope.advanceUntilIdle()
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
}
@Test
fun startInput_callsRestartInput() {
service.startInput()
scope.advanceUntilIdle()
assertThat(inputMethodManager.restartCalls).hasSize(1)
}
@Test
fun startInput_callsShowKeyboard() {
service.startInput()
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
}
@Test
fun stopInput_callsRestartInput() {
service.stopInput()
scope.advanceUntilIdle()
assertThat(inputMethodManager.restartCalls).hasSize(1)
}
@Test
fun stopInput_callsHideKeyboard() {
service.stopInput()
scope.advanceUntilIdle()
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
}
@Test
fun startThenStopInput_onlyCallsRestartOnce() {
service.startInput()
service.stopInput()
scope.advanceUntilIdle()
// Both startInput and stopInput restart the IMM. So calling those two methods back-to-back,
// in either order, should debounce to a single restart call. If they aren't de-duped, the
// keyboard may flicker if one of the calls configures the IME in a non-default way (e.g.
// number input).
assertThat(inputMethodManager.restartCalls).hasSize(1)
}
@Test
fun stopThenStartInput_onlyCallsRestartOnce() {
service.stopInput()
service.startInput()
scope.advanceUntilIdle()
// Both startInput and stopInput restart the IMM. So calling those two methods back-to-back,
// in either order, should debounce to a single restart call. If they aren't de-duped, the
// keyboard may flicker if one of the calls configures the IME in a non-default way (e.g.
// number input).
assertThat(inputMethodManager.restartCalls).hasSize(1)
}
@Test
fun showKeyboard_afterStopInput_isIgnored() {
service.stopInput()
service.showSoftwareKeyboard()
scope.advanceUntilIdle()
// After stopInput, there's no input connection, so any calls to show the keyboard should
// be ignored until the next call to startInput.
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
}
@Test
fun hideKeyboard_afterStopInput_isIgnored() {
service.stopInput()
service.hideSoftwareKeyboard()
scope.advanceUntilIdle()
// stopInput will hide the keyboard implicitly, so both stopInput and hideSoftwareKeyboard
// have the effect "hide the keyboard". These two effects should be debounced and the IMM
// should only get a single hide call instead of two redundant calls.
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
}
@Test
fun multipleShowCallsAreDebounced() {
repeat(10) {
service.showSoftwareKeyboard()
}
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
}
@Test
fun multipleHideCallsAreDebounced() {
repeat(10) {
service.hideSoftwareKeyboard()
}
scope.advanceUntilIdle()
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
}
@Test
fun showThenHideAreDebounced() {
service.showSoftwareKeyboard()
service.hideSoftwareKeyboard()
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).hasSize(0)
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(1)
}
@Test
fun hideThenShowAreDebounced() {
service.hideSoftwareKeyboard()
service.showSoftwareKeyboard()
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).hasSize(1)
assertThat(inputMethodManager.hideSoftInputCalls).hasSize(0)
}
@Test fun stopInput_isNotProcessedImmediately() {
service.stopInput()
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
}
@Test fun startInput_isNotProcessedImmediately() {
service.startInput()
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
}
@Test fun showSoftwareKeyboard_isNotProcessedImmediately() {
service.showSoftwareKeyboard()
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
}
@Test fun hideSoftwareKeyboard_isNotProcessedImmediately() {
service.hideSoftwareKeyboard()
assertThat(inputMethodManager.restartCalls).isEmpty()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
assertThat(inputMethodManager.hideSoftInputCalls).isEmpty()
}
@Test fun commandsAreIgnored_ifFocusLostBeforeProcessing() {
// Send command while view still has focus.
service.showSoftwareKeyboard()
// Blur the view.
whenever(view.isFocused).thenReturn(false)
// Process the queued commands.
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
}
@Test fun commandsAreDrained_whenProcessedWithoutFocus() {
whenever(view.isFocused).thenReturn(false)
service.showSoftwareKeyboard()
service.hideSoftwareKeyboard()
scope.advanceUntilIdle()
whenever(view.isFocused).thenReturn(true)
scope.advanceUntilIdle()
assertThat(inputMethodManager.showSoftInputCalls).isEmpty()
}
private fun TextInputServiceAndroid.startInput() {
startInput(
TextFieldValue(),
ImeOptions.Default,
onEditCommand = {},
onImeActionPerformed = {}
)
}
private class TestInputMethodManager : InputMethodManager {
val restartCalls = mutableListOf<View>()
val showSoftInputCalls = mutableListOf<View>()
val hideSoftInputCalls = mutableListOf<IBinder?>()
override fun restartInput(view: View) {
restartCalls += view
}
override fun showSoftInput(view: View) {
showSoftInputCalls += view
}
override fun hideSoftInputFromWindow(windowToken: IBinder?) {
hideSoftInputCalls += windowToken
}
override fun updateExtractedText(view: View, token: Int, extractedText: ExtractedText) {
}
override fun updateSelection(
view: View,
selectionStart: Int,
selectionEnd: Int,
compositionStart: Int,
compositionEnd: Int
) {
}
}
}