blob: da91076ae3ec5bc8a8e8b52bfc4e51b94b19dfda [file] [log] [blame]
/*
* Copyright 2019 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.text.InputType
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.ViewTreeObserver
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.HideKeyboard
import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.ShowKeyboard
import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.StartInput
import androidx.compose.ui.text.input.TextInputServiceAndroid.TextInputCommand.StopInput
import androidx.core.view.inputmethod.EditorInfoCompat
import kotlin.math.roundToInt
import kotlinx.coroutines.channels.Channel
private const val DEBUG_CLASS = "TextInputServiceAndroid"
/**
* Provide Android specific input service with the Operating System.
*/
internal class TextInputServiceAndroid(
val view: View,
private val inputMethodManager: InputMethodManager
) : PlatformTextInputService {
/**
* Commands that can be sent into [textInputCommandChannel] to be processed by
* [textInputCommandEventLoop].
*/
private enum class TextInputCommand {
StartInput,
StopInput,
ShowKeyboard,
HideKeyboard;
}
/**
* True if the currently editable composable has connected. This is used to tell the platform
* when it asks if the compose view is a text editor.
*/
private var editorHasFocus = false
/**
* The following three observers are set when the editable composable has initiated the input
* session
*/
private var onEditCommand: (List<EditCommand>) -> Unit = {}
private var onImeActionPerformed: (ImeAction) -> Unit = {}
// Visible for testing
internal var state = TextFieldValue(text = "", selection = TextRange.Zero)
private set
private var imeOptions = ImeOptions.Default
private var ic: RecordingInputConnection? = null
// used for sendKeyEvent delegation
private val baseInputConnection by lazy(LazyThreadSafetyMode.NONE) {
BaseInputConnection(view, false)
}
private var focusedRect: android.graphics.Rect? = null
/**
* A channel that is used to debounce rapid operations such as showing/hiding the keyboard and
* starting/stopping input, so we can make the minimal number of calls on the
* [inputMethodManager]. The [TextInputCommand]s sent to this channel are processed by
* [textInputCommandEventLoop].
*/
private val textInputCommandChannel = Channel<TextInputCommand>(Channel.UNLIMITED)
private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener {
// focusedRect is null if there is not ongoing text input session. So safe to request
// latest focused rectangle whenever global layout has changed.
focusedRect?.let {
// Notice that view.requestRectangleOnScreen may modify the input Rect, we have to
// create another Rect and then pass it.
view.requestRectangleOnScreen(android.graphics.Rect(it))
}
}
internal constructor(view: View) : this(view, InputMethodManagerImpl(view.context))
init {
if (DEBUG) { Log.d(TAG, "$DEBUG_CLASS.create") }
view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(v: View?) {
v?.rootView?.viewTreeObserver?.removeOnGlobalLayoutListener(layoutListener)
}
override fun onViewAttachedToWindow(v: View?) {
v?.rootView?.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener)
}
})
}
/**
* Creates new input connection.
*/
fun createInputConnection(outAttrs: EditorInfo): InputConnection? {
if (!editorHasFocus) {
return null
}
outAttrs.update(imeOptions, state)
return RecordingInputConnection(
initState = state,
autoCorrect = imeOptions.autoCorrect,
eventCallback = object : InputEventCallback2 {
override fun onEditCommands(editCommands: List<EditCommand>) {
onEditCommand(editCommands)
}
override fun onImeAction(imeAction: ImeAction) {
onImeActionPerformed(imeAction)
}
override fun onKeyEvent(event: KeyEvent) {
baseInputConnection.sendKeyEvent(event)
}
}
).also {
ic = it
if (DEBUG) { Log.d(TAG, "$DEBUG_CLASS.createInputConnection: $ic") }
}
}
/**
* Returns true if some editable component is focused.
*/
fun isEditorFocused(): Boolean = editorHasFocus
override fun startInput(
value: TextFieldValue,
imeOptions: ImeOptions,
onEditCommand: (List<EditCommand>) -> Unit,
onImeActionPerformed: (ImeAction) -> Unit
) {
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.startInput")
}
editorHasFocus = true
state = value
this.imeOptions = imeOptions
this.onEditCommand = onEditCommand
this.onImeActionPerformed = onImeActionPerformed
// Don't actually send the command to the IME yet, it may be overruled by a subsequent call
// to stopInput.
textInputCommandChannel.trySend(StartInput)
}
override fun stopInput() {
if (DEBUG) Log.d(TAG, "$DEBUG_CLASS.stopInput")
editorHasFocus = false
onEditCommand = {}
onImeActionPerformed = {}
focusedRect = null
// Don't actually send the command to the IME yet, it may be overruled by a subsequent call
// to startInput.
textInputCommandChannel.trySend(StopInput)
}
override fun showSoftwareKeyboard() {
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.showSoftwareKeyboard")
}
textInputCommandChannel.trySend(ShowKeyboard)
}
override fun hideSoftwareKeyboard() {
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.hideSoftwareKeyboard")
}
textInputCommandChannel.trySend(HideKeyboard)
}
/**
* Processes commands from the [textInputCommandChannel] to make the appropriate calls on the
* [inputMethodManager].
*/
suspend fun textInputCommandEventLoop() {
// TODO(b/180071033): Allow for more IMPLICIT flag to be passed.
for (initialCommand in textInputCommandChannel) {
// When focus changes to a non-Compose view, the system will take care of managing the
// keyboard (via ImeFocusController) so we don't need to do anything. This can happen
// when a Compose text field is focused, then the user taps on an EditText view.
// And any commands that come in while we're not focused should also just be ignored,
// since no unfocused view should be messing with the keyboard.
// TODO(b/215761849) When focus moves to a different ComposeView than this one, this
// logic doesn't work and the keyboard is not hidden.
if (!view.isFocused) {
// All queued commands should be ignored, so drain them out of the channel to avoid
// waking up this coroutine again immediately.
do {
val command = textInputCommandChannel.tryReceive()
} while (command.isSuccess)
continue
}
// Multiple commands may have been queued up in the channel while this function was
// waiting to be resumed. We don't execute the commands as they come in because making a
// bunch of calls to change the actual IME quickly can result in flickers. Instead, we
// manually coalesce the commands to figure out the minimum number of IME operations we
// need to get to the desired final state.
// The queued commands effectively operate on a simple state machine consisting of two
// flags:
// 1. Whether to start a new input connection (true), tear down the input connection
// (false), or leave the current connection as-is (null).
var startInput: Boolean? = null
// 2. Whether to show the keyboard (true), hide the keyboard (false), or leave the
// keyboard visibility as-is (null).
var showKeyboard: Boolean? = null
// And a function that performs the appropriate state transition given a command.
fun TextInputCommand.applyToState() {
when (this) {
StartInput -> {
// Any commands before restarting the input are meaningless since they would
// apply to the connection we're going to tear down and recreate.
// Starting a new connection implicitly stops the previous connection.
startInput = true
// It doesn't make sense to start a new connection without the keyboard
// showing.
showKeyboard = true
}
StopInput -> {
startInput = false
// It also doesn't make sense to keep the keyboard visible if it's not
// connected to anything. Note that this is different than the Android
// default behavior for Views, which is to keep the keyboard showing even
// after the view that the IME was shown for loses focus.
// See this doc for some notes and discussion on whether we should auto-hide
// or match Android:
// https://docs.google.com/document/d/1o-y3NkfFPCBhfDekdVEEl41tqtjjqs8jOss6txNgqaw/edit?resourcekey=0-o728aLn51uXXnA4Pkpe88Q#heading=h.ieacosb5rizm
showKeyboard = false
}
ShowKeyboard,
HideKeyboard -> {
// Any keyboard visibility commands sent after input is stopped but before
// input is started should be ignored.
// Otherwise, the last visibility command sent either before the last stop
// command, or after the last start command, is the one that should take
// effect.
if (startInput != false) {
showKeyboard = this == ShowKeyboard
}
}
}
}
// Feed all the queued commands into the state machine.
var command: TextInputCommand? = initialCommand
while (command != null) {
command.applyToState()
if (DEBUG) {
Log.d(
TAG,
"$DEBUG_CLASS.textInputCommandEventLoop.$command " +
"(startInput=$startInput, showKeyboard=$showKeyboard)"
)
}
command = textInputCommandChannel.tryReceive().getOrNull()
}
// Now that we've calculated what operations we need to perform on the actual input
// manager, perform them.
// If the keyboard visibility was changed after starting a new connection, we need to
// perform that operation change after starting it.
// If the keyboard visibility was changed before closing the connection, we need to
// perform that operation before closing the connection so it doesn't no-op.
if (startInput == true) {
restartInputImmediately()
}
showKeyboard?.also(::setKeyboardVisibleImmediately)
if (startInput == false) {
restartInputImmediately()
}
if (DEBUG) Log.d(TAG, "$DEBUG_CLASS.textInputCommandEventLoop.finished")
}
}
override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) {
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.updateState called: $oldValue -> $newValue")
}
// If the selection has changed from the last time, we need to update selection even though
// the oldValue in EditBuffer is already in sync with the newValue.
val needUpdateSelection = (this.state.selection != newValue.selection)
this.state = newValue
// update the latest TextFieldValue in InputConnection
ic?.mTextFieldValue = newValue
if (oldValue == newValue) {
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.updateState early return")
}
if (needUpdateSelection) {
// updateSelection API requires -1 if there is no composition
inputMethodManager.updateSelection(
view = view,
selectionStart = newValue.selection.min,
selectionEnd = newValue.selection.max,
compositionStart = state.composition?.min ?: -1,
compositionEnd = state.composition?.max ?: -1
)
}
return
}
val restartInput = oldValue?.let {
it.text != newValue.text ||
// when selection is the same but composition has changed, need to reset the input.
(it.selection == newValue.selection && it.composition != newValue.composition)
} ?: false
if (DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.updateState: restart($restartInput), state: $state")
}
if (restartInput) {
restartInputImmediately()
} else {
ic?.updateInputState(this.state, inputMethodManager, view)
}
}
override fun notifyFocusedRect(rect: Rect) {
focusedRect = android.graphics.Rect(
rect.left.roundToInt(),
rect.top.roundToInt(),
rect.right.roundToInt(),
rect.bottom.roundToInt()
)
// Requesting rectangle too early after obtaining focus may bring view into wrong place
// probably due to transient IME inset change. We don't know the correct timing of calling
// requestRectangleOnScreen API, so try to call this API only after the IME is ready to
// use, i.e. InputConnection has created.
// Even if we miss all the timing of requesting rectangle during initial text field focus,
// focused rectangle will be requested when software keyboard has shown.
if (ic == null) {
focusedRect?.let {
// Notice that view.requestRectangleOnScreen may modify the input Rect, we have to
// create another Rect and then pass it.
view.requestRectangleOnScreen(android.graphics.Rect(it))
}
}
}
/** Immediately restart the IME connection, bypassing the [textInputCommandChannel]. */
private fun restartInputImmediately() {
if (DEBUG) Log.d(TAG, "$DEBUG_CLASS.restartInputImmediately")
inputMethodManager.restartInput(view)
}
/** Immediately show or hide the keyboard, bypassing the [textInputCommandChannel]. */
private fun setKeyboardVisibleImmediately(visible: Boolean) {
if (DEBUG) Log.d(TAG, "$DEBUG_CLASS.setKeyboardVisibleImmediately(visible=$visible)")
if (visible) {
inputMethodManager.showSoftInput(view)
} else {
inputMethodManager.hideSoftInputFromWindow(view.windowToken)
}
}
}
/**
* Fills necessary info of EditorInfo.
*/
internal fun EditorInfo.update(imeOptions: ImeOptions, textFieldValue: TextFieldValue) {
this.imeOptions = when (imeOptions.imeAction) {
ImeAction.Default -> {
if (imeOptions.singleLine) {
// this is the last resort to enable single line
// Android IME still show return key even if multi line is not send
// TextView.java#onCreateInputConnection
EditorInfo.IME_ACTION_DONE
} else {
EditorInfo.IME_ACTION_UNSPECIFIED
}
}
ImeAction.None -> EditorInfo.IME_ACTION_NONE
ImeAction.Go -> EditorInfo.IME_ACTION_GO
ImeAction.Next -> EditorInfo.IME_ACTION_NEXT
ImeAction.Previous -> EditorInfo.IME_ACTION_PREVIOUS
ImeAction.Search -> EditorInfo.IME_ACTION_SEARCH
ImeAction.Send -> EditorInfo.IME_ACTION_SEND
ImeAction.Done -> EditorInfo.IME_ACTION_DONE
else -> error("invalid ImeAction")
}
when (imeOptions.keyboardType) {
KeyboardType.Text -> this.inputType = InputType.TYPE_CLASS_TEXT
KeyboardType.Ascii -> {
this.inputType = InputType.TYPE_CLASS_TEXT
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_FORCE_ASCII
}
KeyboardType.Number -> this.inputType = InputType.TYPE_CLASS_NUMBER
KeyboardType.Phone -> this.inputType = InputType.TYPE_CLASS_PHONE
KeyboardType.Uri ->
this.inputType = InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI
KeyboardType.Email ->
this.inputType =
InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
KeyboardType.Password -> {
this.inputType =
InputType.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
}
KeyboardType.NumberPassword -> {
this.inputType =
InputType.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD
}
else -> error("Invalid Keyboard Type")
}
if (!imeOptions.singleLine) {
if (hasFlag(this.inputType, InputType.TYPE_CLASS_TEXT)) {
// TextView.java#setInputTypeSingleLine
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
if (imeOptions.imeAction == ImeAction.Default) {
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_NO_ENTER_ACTION
}
}
}
if (hasFlag(this.inputType, InputType.TYPE_CLASS_TEXT)) {
when (imeOptions.capitalization) {
KeyboardCapitalization.Characters -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
}
KeyboardCapitalization.Words -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_WORDS
}
KeyboardCapitalization.Sentences -> {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
}
else -> {
/* do nothing */
}
}
if (imeOptions.autoCorrect) {
this.inputType = this.inputType or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
}
}
this.initialSelStart = textFieldValue.selection.start
this.initialSelEnd = textFieldValue.selection.end
EditorInfoCompat.setInitialSurroundingText(this, textFieldValue.text)
this.imeOptions = this.imeOptions or EditorInfo.IME_FLAG_NO_FULLSCREEN
}
private fun hasFlag(bits: Int, flag: Int): Boolean = (bits and flag) == flag