/*
* Copyright 2023 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.foundation.text2.input.internal
import android.os.Bundle
import android.os.Handler
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
import android.view.inputmethod.CompletionInfo
import android.view.inputmethod.CorrectionInfo
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.ExtractedText
import android.view.inputmethod.ExtractedTextRequest
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputContentInfo
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text2.input.TextFieldCharSequence
import androidx.compose.ui.text.TextRange
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.math.max
import kotlin.math.min
@VisibleForTesting
internal const val SIC_DEBUG = false
private const val TAG = "StatelessIC"
private const val DEBUG_CLASS = "StatelessInputConnection"
private val EmptyTextFieldValue = TextFieldValue()
/**
* An input connection that delegates its reads and writes to the active text input session in
* [AndroidTextInputAdapter]. InputConnections are requested and used by framework to create bridge
* from IME to an active editor.
*/
@OptIn(ExperimentalFoundationApi::class)
internal class StatelessInputConnection(
private val activeSessionProvider: () -> EditableTextInputSession?
) : InputConnection {
/**
* The depth of the batch session. 0 means no session.
*
* Sometimes InputConnection does not call begin/endBatchEdit functions before calling other
* edit functions like commitText or setComposingText. StatelessInputConnection starts and
* finishes a new artificial batch for every EditCommand to make sure that there is always
* an ongoing batch. EditCommands are only applied when batchDepth reaches 0.
*/
private var batchDepth: Int = 0
/**
* The input state from the currently active [TextInputSession] in
* [AndroidTextInputAdapter]. Returns null if there is no active session.
*/
private val valueOrNull: TextFieldCharSequence?
get() = activeSessionProvider()?.value
/**
* The input state from the currently active [TextInputSession] in
* [AndroidTextInputAdapter]. Returns empty TextFieldValue if there is no active session.
*/
private val value: TextFieldCharSequence
get() = valueOrNull ?: TextFieldCharSequence()
/**
* Recording of editing operations for batch editing
*/
private val editCommands = mutableListOf<EditCommand>()
/**
* If this InputConnection itself is active. This value becomes false only if [closeConnection]
* gets called.
*/
private var isICActive: Boolean = true
/**
* Returns whether this input connection is still active and also executes the given lambda if
* it is active.
*/
private inline fun ensureActive(block: () -> Unit): Boolean {
val combinedActive = isICActive && activeSessionProvider() != null
return combinedActive.also {
if (it) {
block()
}
}
}
/**
* Add edit op to internal list with wrapping batch edit. It's not guaranteed by IME that
* batch editing will be used for every operation. Instead, [StatelessInputConnection] creates
* its own mini batches for every edit op. These batches are only applied when batch depth
* reaches 0, meaning that artificial batches won't be applied until the real batches are
* completed.
*/
private fun addEditCommandWithBatch(editCommand: EditCommand) {
beginBatchEditInternal()
try {
editCommands.add(editCommand)
} finally {
endBatchEditInternal()
}
}
// region Methods for batch editing and session control
override fun beginBatchEdit(): Boolean = ensureActive {
logDebug("beginBatchEdit()")
return beginBatchEditInternal()
}
private fun beginBatchEditInternal(): Boolean {
batchDepth++
return true
}
override fun endBatchEdit(): Boolean {
logDebug("endBatchEdit()")
return endBatchEditInternal()
}
private fun endBatchEditInternal(): Boolean {
batchDepth--
if (batchDepth == 0 && editCommands.isNotEmpty()) {
// apply the changes to active input session.
activeSessionProvider()?.requestEdits(editCommands.toMutableList())
editCommands.clear()
}
return batchDepth > 0
}
override fun closeConnection() {
logDebug("closeConnection()")
editCommands.clear()
batchDepth = 0
isICActive = false
}
//endregion
// region Callbacks for text editing
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean = ensureActive {
logDebug("commitText(\"$text\", $newCursorPosition)")
addEditCommandWithBatch(CommitTextCommand(text.toString(), newCursorPosition))
}
override fun setComposingRegion(start: Int, end: Int): Boolean = ensureActive {
logDebug("setComposingRegion($start, $end)")
addEditCommandWithBatch(SetComposingRegionCommand(start, end))
}
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean =
ensureActive {
logDebug("setComposingText(\"$text\", $newCursorPosition)")
addEditCommandWithBatch(SetComposingTextCommand(text.toString(), newCursorPosition))
}
override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
ensureActive {
logDebug("deleteSurroundingTextInCodePoints($beforeLength, $afterLength)")
addEditCommandWithBatch(
DeleteSurroundingTextInCodePointsCommand(beforeLength, afterLength)
)
return true
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
ensureActive {
logDebug("deleteSurroundingText($beforeLength, $afterLength)")
addEditCommandWithBatch(DeleteSurroundingTextCommand(beforeLength, afterLength))
return true
}
override fun setSelection(start: Int, end: Int): Boolean = ensureActive {
logDebug("setSelection($start, $end)")
addEditCommandWithBatch(SetSelectionCommand(start, end))
return true
}
override fun finishComposingText(): Boolean = ensureActive {
logDebug("finishComposingText()")
addEditCommandWithBatch(FinishComposingTextCommand)
return true
}
override fun sendKeyEvent(event: KeyEvent): Boolean = ensureActive {
logDebug("sendKeyEvent($event)")
activeSessionProvider()?.sendKeyEvent(event)
return true
}
// endregion
// region Callbacks for retrieving editing buffer info by IME
override fun getTextBeforeCursor(maxChars: Int, flags: Int): CharSequence {
// TODO(b/135556699) should return styled text
val result = value.getTextBeforeSelection(maxChars).toString()
logDebug("getTextBeforeCursor($maxChars, $flags): $result")
return result
}
override fun getTextAfterCursor(maxChars: Int, flags: Int): CharSequence {
// TODO(b/135556699) should return styled text
val result = value.getTextAfterSelection(maxChars).toString()
logDebug("getTextAfterCursor($maxChars, $flags): $result")
return result
}
override fun getSelectedText(flags: Int): CharSequence? {
// https://source.chromium.org/chromium/chromium/src/+/master:content/public/android/java/src/org/chromium/content/browser/input/TextInputState.java;l=56;drc=0e20d1eb38227949805a4c0e9d5cdeddc8d23637
val result: CharSequence? = if (value.selectionInChars.collapsed) {
null
} else {
// TODO(b/135556699) should return styled text
value.getSelectedText().toString()
}
logDebug("getSelectedText($flags): $result")
return result
}
override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean = ensureActive {
logDebug("requestCursorUpdates($cursorUpdateMode)")
return false
}
override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText {
logDebug("getExtractedText($request, $flags)")
// extractedTextMonitorMode = (flags and InputConnection.GET_EXTRACTED_TEXT_MONITOR) != 0
// if (extractedTextMonitorMode) {
// currentExtractedTextRequestToken = request?.token ?: 0
// }
// TODO(halilibo): Implement extracted text monitor
// TODO(b/135556699) should return styled text
return value.toExtractedText()
}
override fun getCursorCapsMode(reqModes: Int): Int {
logDebug("getCursorCapsMode($reqModes)")
return TextUtils.getCapsMode(value, value.selectionInChars.min, reqModes)
}
// endregion
// region Editor action and Key events.
override fun performContextMenuAction(id: Int): Boolean = ensureActive {
logDebug("performContextMenuAction($id)")
when (id) {
android.R.id.selectAll -> {
addEditCommandWithBatch(SetSelectionCommand(0, value.length))
}
// TODO(siyamed): Need proper connection to cut/copy/paste
android.R.id.cut -> sendSynthesizedKeyEvent(KeyEvent.KEYCODE_CUT)
android.R.id.copy -> sendSynthesizedKeyEvent(KeyEvent.KEYCODE_COPY)
android.R.id.paste -> sendSynthesizedKeyEvent(KeyEvent.KEYCODE_PASTE)
android.R.id.startSelectingText -> {} // not supported
android.R.id.stopSelectingText -> {} // not supported
android.R.id.copyUrl -> {} // not supported
android.R.id.switchInputMethod -> {} // not supported
else -> {
// not supported
}
}
return false
}
private fun sendSynthesizedKeyEvent(code: Int) {
sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, code))
sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, code))
}
override fun performEditorAction(editorAction: Int): Boolean = ensureActive {
logDebug("performEditorAction($editorAction)")
val imeAction = when (editorAction) {
EditorInfo.IME_ACTION_UNSPECIFIED -> ImeAction.Default
EditorInfo.IME_ACTION_DONE -> ImeAction.Done
EditorInfo.IME_ACTION_SEND -> ImeAction.Send
EditorInfo.IME_ACTION_SEARCH -> ImeAction.Search
EditorInfo.IME_ACTION_PREVIOUS -> ImeAction.Previous
EditorInfo.IME_ACTION_NEXT -> ImeAction.Next
EditorInfo.IME_ACTION_GO -> ImeAction.Go
else -> {
logDebug("IME sent an unrecognized editor action: $editorAction")
ImeAction.Default
}
}
activeSessionProvider()?.onImeAction(imeAction)
return true
}
// endregion
// region Unsupported callbacks
override fun commitCompletion(text: CompletionInfo?): Boolean {
logDebug("commitCompletion(${text?.text})")
// We don't support this callback.
// The API documents says this should return if the input connection is no longer valid, but
// The Chromium implementation already returning false, so assuming it is safe to return
// false if not supported.
// see https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java
return false
}
override fun commitCorrection(correctionInfo: CorrectionInfo?): Boolean {
// logDebug("commitCorrection($correctionInfo),autoCorrect:$autoCorrect")
// Should add an event here so that we can implement the autocorrect highlight
// Bug: 170647219
// TODO(halilibo): Implement autoCorrect from ImeOptions
return true
}
override fun getHandler(): Handler? {
logDebug("getHandler()")
return null // Returns null means using default Handler
}
override fun clearMetaKeyStates(states: Int): Boolean {
logDebug("clearMetaKeyStates($states)")
// We don't support this callback.
// The API documents says this should return if the input connection is no longer valid, but
// The Chromium implementation already returning false, so assuming it is safe to return
// false if not supported.
// see https://cs.chromium.org/chromium/src/content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java
return false
}
override fun reportFullscreenMode(enabled: Boolean): Boolean {
logDebug("reportFullscreenMode($enabled)")
return false // This value is ignored according to the API docs.
}
override fun performPrivateCommand(action: String?, data: Bundle?): Boolean = ensureActive {
logDebug("performPrivateCommand($action, $data)")
return true // API doc says we should return true even if we didn't understand the command.
}
override fun commitContent(
inputContentInfo: InputContentInfo,
flags: Int,
opts: Bundle?
): Boolean = ensureActive {
logDebug("commitContent($inputContentInfo, $flags, $opts)")
// TODO(halilibo): Support commit content in BasicTextField2
return false
}
// endregion
/**
* Returns the text before the selection.
*
* @param maxChars maximum number of characters (inclusive) before the minimum value in
* [TextFieldCharSequence.selectionInChars].
*
* @see TextRange.min
*/
fun TextFieldCharSequence.getTextBeforeSelection(maxChars: Int): CharSequence =
subSequence(max(0, selectionInChars.min - maxChars), selectionInChars.min)
/**
* Returns the text after the selection.
*
* @param maxChars maximum number of characters (exclusive) after the maximum value in
* [TextFieldCharSequence.selectionInChars].
*
* @see TextRange.max
*/
fun TextFieldCharSequence.getTextAfterSelection(maxChars: Int): CharSequence =
subSequence(selectionInChars.max, min(selectionInChars.max + maxChars, length))
/**
* Returns the currently selected text.
*/
fun TextFieldCharSequence.getSelectedText(): CharSequence =
subSequence(selectionInChars.min, selectionInChars.max)
private fun logDebug(message: String) {
if (SIC_DEBUG) {
Log.d(TAG, "$DEBUG_CLASS.$message, $isICActive, ${activeSessionProvider() != null}")
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun TextFieldCharSequence.toExtractedText(): ExtractedText {
val res = ExtractedText()
res.text = this
res.startOffset = 0
res.partialEndOffset = length
res.partialStartOffset = -1 // -1 means full text
res.selectionStart = selectionInChars.min
res.selectionEnd = selectionInChars.max
res.flags = if ('\n' in this) 0 else ExtractedText.FLAG_SINGLE_LINE
return res
}