| /* |
| * 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 |