RecordingInputConnection.android.kt

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

internal const val DEBUG = false
internal const val TAG = "RecordingIC"
private const val DEBUG_CLASS = "RecordingInputConnection"

/**
 * [InputConnection] implementation that binds Android IME to Compose.
 *
 * @param initState The initial input state.
 * @param eventCallback An input event listener.
 * @param autoCorrect Whether autoCorrect is enabled.
 */
internal class RecordingInputConnection(
    initState: TextFieldValue,
    val eventCallback: InputEventCallback2,
    val autoCorrect: Boolean
) : InputConnection {

    // The depth of the batch session. 0 means no session.
    private var batchDepth: Int = 0

    // The input state.
    internal var mTextFieldValue: TextFieldValue = initState
        set(value) {
            if (DEBUG) { logDebug("mTextFieldValue : $field -> $value") }
            field = value
        }

    /**
     * The token to be used for reporting updateExtractedText API.
     *
     * 0 if no token was specified from IME.
     */
    private var currentExtractedTextRequestToken = 0

    /**
     * True if IME requested extracted text monitor mode.
     *
     * If extracted text monitor mode is ON, need to call updateExtractedText API whenever the text
     * is changed.
     */
    private var extractedTextMonitorMode = false

    // The recoding editing ops.
    private val editCommands = mutableListOf<EditCommand>()

    private var isActive: Boolean = true

    private inline fun ensureActive(block: () -> Unit): Boolean {
        return isActive.also { applying ->
            if (applying) {
                block()
            }
        }
    }

    /**
     * Updates the input state and tells it to the IME.
     *
     * This function may emits updateSelection and updateExtractedText to notify IMEs that the text
     * contents has changed if needed.
     */
    fun updateInputState(
        state: TextFieldValue,
        inputMethodManager: InputMethodManager,
    ) {
        if (!isActive) return

        if (DEBUG) { logDebug("RecordingInputConnection.updateInputState: $state") }

        mTextFieldValue = state

        if (extractedTextMonitorMode) {
            inputMethodManager.updateExtractedText(
                currentExtractedTextRequestToken,
                state.toExtractedText()
            )
        }

        // updateSelection API requires -1 if there is no composition
        val compositionStart = state.composition?.min ?: -1
        val compositionEnd = state.composition?.max ?: -1
        if (DEBUG) {
            logDebug(
                "updateSelection(" +
                    "selection = (${state.selection.min},${state.selection.max}), " +
                    "composition = ($compositionStart, $compositionEnd))"
            )
        }
        inputMethodManager.updateSelection(
            state.selection.min, state.selection.max, compositionStart, compositionEnd
        )
    }

    // Add edit op to internal list with wrapping batch edit.
    private fun addEditCommandWithBatch(editCommand: EditCommand) {
        beginBatchEditInternal()
        try {
            editCommands.add(editCommand)
        } finally {
            endBatchEditInternal()
        }
    }

    // /////////////////////////////////////////////////////////////////////////////////////////////
    // Callbacks for text editing session
    // /////////////////////////////////////////////////////////////////////////////////////////////

    override fun beginBatchEdit(): Boolean = ensureActive {
        if (DEBUG) { logDebug("beginBatchEdit()") }
        return beginBatchEditInternal()
    }

    private fun beginBatchEditInternal(): Boolean {
        batchDepth++
        return true
    }

    override fun endBatchEdit(): Boolean {
        if (DEBUG) { logDebug("endBatchEdit()") }
        return endBatchEditInternal()
    }

    private fun endBatchEditInternal(): Boolean {
        batchDepth--
        if (batchDepth == 0 && editCommands.isNotEmpty()) {
            eventCallback.onEditCommands(editCommands.toMutableList())
            editCommands.clear()
        }
        return batchDepth > 0
    }

    override fun closeConnection() {
        if (DEBUG) { logDebug("closeConnection()") }
        editCommands.clear()
        batchDepth = 0
        isActive = false
        eventCallback.onConnectionClosed(this)
    }

    // /////////////////////////////////////////////////////////////////////////////////////////////
    // Callbacks for text editing
    // /////////////////////////////////////////////////////////////////////////////////////////////

    override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean = ensureActive {
        if (DEBUG) { logDebug("commitText(\"$text\", $newCursorPosition)") }
        addEditCommandWithBatch(CommitTextCommand(text.toString(), newCursorPosition))
    }

    override fun setComposingRegion(start: Int, end: Int): Boolean = ensureActive {
        if (DEBUG) { logDebug("setComposingRegion($start, $end)") }
        addEditCommandWithBatch(SetComposingRegionCommand(start, end))
    }

    override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean =
        ensureActive {
            if (DEBUG) {
                logDebug("setComposingText(\"$text\", $newCursorPosition)")
            }
            addEditCommandWithBatch(SetComposingTextCommand(text.toString(), newCursorPosition))
        }

    override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean =
        ensureActive {
            if (DEBUG) {
                logDebug("deleteSurroundingTextInCodePoints($beforeLength, $afterLength)")
            }
            addEditCommandWithBatch(
                DeleteSurroundingTextInCodePointsCommand(beforeLength, afterLength)
            )
            return true
        }

    override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean =
        ensureActive {
            if (DEBUG) { logDebug("deleteSurroundingText($beforeLength, $afterLength)") }
            addEditCommandWithBatch(DeleteSurroundingTextCommand(beforeLength, afterLength))
            return true
        }

    override fun setSelection(start: Int, end: Int): Boolean = ensureActive {
        if (DEBUG) { logDebug("setSelection($start, $end)") }
        addEditCommandWithBatch(SetSelectionCommand(start, end))
        return true
    }

    override fun finishComposingText(): Boolean = ensureActive {
        if (DEBUG) { logDebug("finishComposingText()") }
        addEditCommandWithBatch(FinishComposingTextCommand())
        return true
    }

    override fun sendKeyEvent(event: KeyEvent): Boolean = ensureActive {
        if (DEBUG) { logDebug("sendKeyEvent($event)") }
        eventCallback.onKeyEvent(event)
        return true
    }

    // /////////////////////////////////////////////////////////////////////////////////////////////
    // Callbacks for retrieving editing buffers
    // /////////////////////////////////////////////////////////////////////////////////////////////

    override fun getTextBeforeCursor(maxChars: Int, flags: Int): CharSequence {
        // TODO(b/135556699) should return styled text
        val result = mTextFieldValue.getTextBeforeSelection(maxChars).toString()
        if (DEBUG) { logDebug("getTextBeforeCursor($maxChars, $flags): $result") }
        return result
    }

    override fun getTextAfterCursor(maxChars: Int, flags: Int): CharSequence {
        // TODO(b/135556699) should return styled text
        val result = mTextFieldValue.getTextAfterSelection(maxChars).toString()
        if (DEBUG) { 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 (mTextFieldValue.selection.collapsed) {
            null
        } else {
            // TODO(b/135556699) should return styled text
            mTextFieldValue.getSelectedText().toString()
        }
        if (DEBUG) { logDebug("getSelectedText($flags): $result") }
        return result
    }

    override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean = ensureActive {
        if (DEBUG) { logDebug("requestCursorUpdates($cursorUpdateMode)") }
        Log.w(TAG, "requestCursorUpdates is not supported")
        return false
    }

    override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText {
        if (DEBUG) { logDebug("getExtractedText($request, $flags)") }
        extractedTextMonitorMode = (flags and InputConnection.GET_EXTRACTED_TEXT_MONITOR) != 0
        if (extractedTextMonitorMode) {
            currentExtractedTextRequestToken = request?.token ?: 0
        }
        // TODO(b/135556699) should return styled text
        val extractedText = mTextFieldValue.toExtractedText()

        if (DEBUG) {
            with(extractedText) {
                logDebug(

                    "getExtractedText() return: text: \"$text\"" +
                        ",partialStartOffset $partialStartOffset" +
                        ",partialEndOffset $partialEndOffset" +
                        ",selectionStart $selectionStart" +
                        ",selectionEnd $selectionEnd" +
                        ",flags $flags"
                )
            }
        }

        return extractedText
    }

    // /////////////////////////////////////////////////////////////////////////////////////////////
    // Editor action and Key events.
    // /////////////////////////////////////////////////////////////////////////////////////////////

    override fun performContextMenuAction(id: Int): Boolean = ensureActive {
        if (DEBUG) { logDebug("performContextMenuAction($id)") }
        when (id) {
            android.R.id.selectAll -> {
                addEditCommandWithBatch(SetSelectionCommand(0, mTextFieldValue.text.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 {
        if (DEBUG) { 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 -> {
                Log.w(TAG, "IME sends unsupported Editor Action: $editorAction")
                ImeAction.Default
            }
        }
        eventCallback.onImeAction(imeAction)
        return true
    }

    // /////////////////////////////////////////////////////////////////////////////////////////////
    // Unsupported callbacks
    // /////////////////////////////////////////////////////////////////////////////////////////////

    override fun commitCompletion(text: CompletionInfo?): Boolean = ensureActive {
        if (DEBUG) { 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 = ensureActive {
        if (DEBUG) { logDebug("commitCorrection($correctionInfo),autoCorrect:$autoCorrect") }
        // Should add an event here so that we can implement the autocorrect highlight
        // Bug: 170647219
        return autoCorrect
    }

    override fun getHandler(): Handler? {
        if (DEBUG) { logDebug("getHandler()") }
        return null // Returns null means using default Handler
    }

    override fun clearMetaKeyStates(states: Int): Boolean = ensureActive {
        if (DEBUG) { 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 {
        if (DEBUG) { logDebug("reportFullscreenMode($enabled)") }
        return false // This value is ignored according to the API docs.
    }

    override fun getCursorCapsMode(reqModes: Int): Int {
        if (DEBUG) { logDebug("getCursorCapsMode($reqModes)") }
        return TextUtils.getCapsMode(mTextFieldValue.text, mTextFieldValue.selection.min, reqModes)
    }

    override fun performPrivateCommand(action: String?, data: Bundle?): Boolean = ensureActive {
        if (DEBUG) { 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 {
        if (DEBUG) { logDebug("commitContent($inputContentInfo, $flags, $opts)") }
        return false // We don't accept any contents.
    }

    private fun logDebug(message: String) {
        if (DEBUG) { Log.d(TAG, "$DEBUG_CLASS.$message, $isActive") }
    }
}