Raw File
GeckoInputConnection.java
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko;

import java.io.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

import android.os.*;
import android.app.*;
import android.text.*;
import android.text.style.*;
import android.view.*;
import android.view.inputmethod.*;
import android.content.*;
import android.R;

import android.util.*;

public class GeckoInputConnection
    extends BaseInputConnection
    implements TextWatcher
{
    private class ChangeNotification {
        public String mText;
        public int mStart;
        public int mEnd;
        public int mNewEnd;

        ChangeNotification(String text, int start, int oldEnd, int newEnd) {
            mText = text;
            mStart = start;
            mEnd = oldEnd;
            mNewEnd = newEnd;
        }

        ChangeNotification(int start, int end) {
            mText = null;
            mStart = start;
            mEnd = end;
            mNewEnd = 0;
        }
    }

    public GeckoInputConnection (View targetView) {
        super(targetView, true);
        mQueryResult = new SynchronousQueue<String>();
    }

    @Override
    public boolean beginBatchEdit() {
        //Log.d("GeckoAppJava", "IME: beginBatchEdit");
        mBatchMode = true;
        return true;
    }

    @Override
    public boolean commitCompletion(CompletionInfo text) {
        //Log.d("GeckoAppJava", "IME: commitCompletion");

        return commitText(text.getText(), 1);
    }

    @Override
    public boolean commitText(CharSequence text, int newCursorPosition) {
        //Log.d("GeckoAppJava", "IME: commitText");

        setComposingText(text, newCursorPosition);
        finishComposingText();

        return true;
    }

    @Override
    public boolean deleteSurroundingText(int leftLength, int rightLength) {
        //Log.d("GeckoAppJava", "IME: deleteSurroundingText");
        if (leftLength == 0 && rightLength == 0)
            return true;

        /* deleteSurroundingText is supposed to ignore the composing text,
            so we cancel any pending composition, delete the text, and then
            restart the composition */

        if (mComposing) {
            // Cancel current composition
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(0, 0, 0, 0, 0, 0, null));
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_END, 0, 0));
        }

        // Select text to be deleted
        int delStart, delLen;
        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_SELECTION, 0, 0));
        try {
            mQueryResult.take();
        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: deleteSurroundingText interrupted", e);
            return false;
        }
        delStart = mSelectionStart > leftLength ?
                    mSelectionStart - leftLength : 0;
        delLen = mSelectionStart + rightLength - delStart;
        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_SET_SELECTION, delStart, delLen));

        // Restore composition / delete text
        if (mComposing) {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_BEGIN, 0, 0));
            if (mComposingText.length() > 0) {
                /* IME_SET_TEXT doesn't work well with empty strings */
                GeckoAppShell.sendEventToGecko(
                    new GeckoEvent(0, mComposingText.length(),
                                   GeckoEvent.IME_RANGE_RAWINPUT,
                                   GeckoEvent.IME_RANGE_UNDERLINE, 0, 0,
                                   mComposingText.toString()));
            }
        } else {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_DELETE_TEXT, 0, 0));
        }
        return true;
    }

    @Override
    public boolean endBatchEdit() {
        //Log.d("GeckoAppJava", "IME: endBatchEdit");

        mBatchMode = false;

        if (!mBatchChanges.isEmpty()) {
            InputMethodManager imm = (InputMethodManager)
                GeckoApp.surfaceView.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
            if (imm != null) {
                for (ChangeNotification n : mBatchChanges) {
                    if (n.mText != null)
                        notifyTextChange(imm, n.mText, n.mStart, n.mEnd, n.mNewEnd);
                    else
                        notifySelectionChange(imm, n.mStart, n.mEnd);
                }
            }
            mBatchChanges.clear();
        }
        return true;
    }

    @Override
    public boolean finishComposingText() {
        //Log.d("GeckoAppJava", "IME: finishComposingText");

        if (mComposing) {
            // Set style to none
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(0, mComposingText.length(),
                               GeckoEvent.IME_RANGE_RAWINPUT, 0, 0, 0,
                               mComposingText));
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_END, 0, 0));
            mComposing = false;
            mComposingText = "";

            if (!mBatchMode) {
                // Make sure caret stays at the same position
                GeckoAppShell.sendEventToGecko(
                    new GeckoEvent(GeckoEvent.IME_SET_SELECTION,
                                   mCompositionStart + mCompositionSelStart, 0));
            }
        }
        return true;
    }

    @Override
    public int getCursorCapsMode(int reqModes) {
        //Log.d("GeckoAppJava", "IME: getCursorCapsMode");

        return 0;
    }

    @Override
    public Editable getEditable() {
        Log.w("GeckoAppJava", "IME: getEditable called from " +
            Thread.currentThread().getStackTrace()[0].toString());

        return null;
    }

    @Override
    public boolean performContextMenuAction(int id) {
        //Log.d("GeckoAppJava", "IME: performContextMenuAction");

        // First we need to ask Gecko to tell us the full contents of the
        // text field we're about to operate on.
        String text;
        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_TEXT, 0, Integer.MAX_VALUE));
        try {
            text = mQueryResult.take();
        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: performContextMenuAction interrupted", e);
            return false;
        }

        switch (id) {
            case R.id.selectAll:
                setSelection(0, text.length());
                break;
            case R.id.cut:
                // Fill the clipboard
                GeckoAppShell.setClipboardText(text);
                // If GET_TEXT returned an empty selection, we'll select everything
                if (mSelectionLength <= 0)
                    GeckoAppShell.sendEventToGecko(
                        new GeckoEvent(GeckoEvent.IME_SET_SELECTION, 0, text.length()));
                GeckoAppShell.sendEventToGecko(
                    new GeckoEvent(GeckoEvent.IME_DELETE_TEXT, 0, 0));
                break;
            case R.id.paste:
                commitText(GeckoAppShell.getClipboardText(), 1);
                break;
            case R.id.copy:
                // If there is no selection set, we must be doing "Copy All",
                // otherwise, we need to get the selection from Gecko
                if (mSelectionLength > 0) {
                    GeckoAppShell.sendEventToGecko(
                        new GeckoEvent(GeckoEvent.IME_GET_SELECTION, 0, 0));
                    try {
                        text = mQueryResult.take();
                    } catch (InterruptedException e) {
                        Log.e("GeckoAppJava", "IME: performContextMenuAction interrupted", e);
                        return false;
                    }
                }
                GeckoAppShell.setClipboardText(text);
                break;
        }
        return true;
    }

    @Override
    public ExtractedText getExtractedText(ExtractedTextRequest req, int flags) {
        if (req == null)
            return null;

        // Bail out here if gecko isn't running, otherwise we deadlock
        // below when waiting for the reply to IME_GET_SELECTION.
        if (!GeckoApp.checkLaunchState(GeckoApp.LaunchState.GeckoRunning))
            return null;

        //Log.d("GeckoAppJava", "IME: getExtractedText");

        ExtractedText extract = new ExtractedText();
        extract.flags = 0;
        extract.partialStartOffset = -1;
        extract.partialEndOffset = -1;

        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_SELECTION, 0, 0));
        try {
            mQueryResult.take();
        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: getExtractedText interrupted", e);
            return null;
        }
        extract.selectionStart = mSelectionStart;
        extract.selectionEnd = mSelectionStart + mSelectionLength;

        // bug 617298 - IME_GET_TEXT sometimes gives the wrong result due to
        // a stale cache. Use a set of three workarounds:
        // 1. Sleep for 20 milliseconds and hope the child updates us with the new text.
        //    Very evil and, consequentially, most effective.
        try {
            Thread.sleep(20);
        } catch (InterruptedException e) {}

        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_TEXT, 0, Integer.MAX_VALUE));
        try {
            extract.startOffset = 0;
            extract.text = mQueryResult.take();

            // 2. Make a guess about what the text actually is
            if (mComposing && extract.selectionEnd > extract.text.length())
                extract.text = extract.text.subSequence(0, Math.min(extract.text.length(), mCompositionStart)) + mComposingText;

            // 3. If all else fails, make sure our selection indexes make sense
            extract.selectionStart = Math.min(extract.selectionStart, extract.text.length());
            extract.selectionEnd = Math.min(extract.selectionEnd, extract.text.length());

            if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0)
                mUpdateRequest = req;
            return extract;

        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: getExtractedText interrupted", e);
            return null;
        }
    }

    @Override
    public CharSequence getTextAfterCursor(int length, int flags) {
        //Log.d("GeckoAppJava", "IME: getTextAfterCursor");

        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_SELECTION, 0, 0));
        try {
            mQueryResult.take();
        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: getTextBefore/AfterCursor interrupted", e);
            return null;
        }

        /* Compatible with both positive and negative length
            (no need for separate code for getTextBeforeCursor) */
        int textStart = mSelectionStart;
        int textLength = length;

        if (length < 0) {
          textStart += length;
          textLength = -length;
          if (textStart < 0) {
            textStart = 0;
            textLength = mSelectionStart;
          }
        }

        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_GET_TEXT, textStart, textLength));
        try {
            return mQueryResult.take();
        } catch (InterruptedException e) {
            Log.e("GeckoAppJava", "IME: getTextBefore/AfterCursor: Interrupted!", e);
            return null;
        }
    }

    @Override
    public CharSequence getTextBeforeCursor(int length, int flags) {
        //Log.d("GeckoAppJava", "IME: getTextBeforeCursor");

        return getTextAfterCursor(-length, flags);
    }

    @Override
    public boolean setComposingText(CharSequence text, int newCursorPosition) {
        //Log.d("GeckoAppJava", "IME: setComposingText");

        // Set new composing text
        mComposingText = text != null ? text.toString() : "";

        if (!mComposing) {
            if (mComposingText.length() == 0) {
                // Some IMEs such as iWnn sometimes call with empty composing 
                // text.  (See bug 664364)
                // If composing text is empty, ignore this and don't start
                // compositing.
                return true;
            }

            // Get current selection
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_GET_SELECTION, 0, 0));
            try {
                mQueryResult.take();
            } catch (InterruptedException e) {
                Log.e("GeckoAppJava", "IME: setComposingText interrupted", e);
                return false;
            }
            // Make sure we are in a composition
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_BEGIN, 0, 0));
            mComposing = true;
            mCompositionStart = mSelectionLength >= 0 ?
                mSelectionStart : mSelectionStart + mSelectionLength;
        }

        // Set new selection
        // New selection should be within the composition
        mCompositionSelStart = newCursorPosition > 0 ? mComposingText.length() : 0;
        mCompositionSelLen = 0;

        // Handle composition text styles
        if (text != null && text instanceof Spanned) {
            Spanned span = (Spanned) text;
            int spanStart = 0, spanEnd = 0;
            boolean pastSelStart = false, pastSelEnd = false;

            do {
                int rangeType = GeckoEvent.IME_RANGE_CONVERTEDTEXT;
                int rangeStyles = 0, rangeForeColor = 0, rangeBackColor = 0;

                // Find next offset where there is a style transition
                spanEnd = span.nextSpanTransition(spanStart + 1, text.length(),
                    CharacterStyle.class);

                // We need to count the selection as a transition
                if (mCompositionSelLen >= 0) {
                    if (!pastSelStart && spanEnd >= mCompositionSelStart) {
                        spanEnd = mCompositionSelStart;
                        pastSelStart = true;
                    } else if (!pastSelEnd && spanEnd >=
                            mCompositionSelStart + mCompositionSelLen) {
                        spanEnd = mCompositionSelStart + mCompositionSelLen;
                        pastSelEnd = true;
                        rangeType = GeckoEvent.IME_RANGE_SELECTEDRAWTEXT;
                    }
                } else {
                    if (!pastSelEnd && spanEnd >=
                            mCompositionSelStart + mCompositionSelLen) {
                        spanEnd = mCompositionSelStart + mCompositionSelLen;
                        pastSelEnd = true;
                    } else if (!pastSelStart &&
                            spanEnd >= mCompositionSelStart) {
                        spanEnd = mCompositionSelStart;
                        pastSelStart = true;
                        rangeType = GeckoEvent.IME_RANGE_SELECTEDRAWTEXT;
                    }
                }
                // Empty range, continue
                if (spanEnd <= spanStart)
                    continue;

                // Get and iterate through list of span objects within range
                CharacterStyle styles[] = span.getSpans(
                    spanStart, spanEnd, CharacterStyle.class);

                for (CharacterStyle style : styles) {
                    if (style instanceof UnderlineSpan) {
                        // Text should be underlined
                        rangeStyles |= GeckoEvent.IME_RANGE_UNDERLINE;

                    } else if (style instanceof ForegroundColorSpan) {
                        // Text should be of a different foreground color
                        rangeStyles |= GeckoEvent.IME_RANGE_FORECOLOR;
                        rangeForeColor =
                            ((ForegroundColorSpan)style).getForegroundColor();

                    } else if (style instanceof BackgroundColorSpan) {
                        // Text should be of a different background color
                        rangeStyles |= GeckoEvent.IME_RANGE_BACKCOLOR;
                        rangeBackColor =
                            ((BackgroundColorSpan)style).getBackgroundColor();
                    }
                }

                // Add range to array, the actual styles are
                //  applied when IME_SET_TEXT is sent
                GeckoAppShell.sendEventToGecko(
                    new GeckoEvent(spanStart, spanEnd - spanStart,
                                   rangeType, rangeStyles,
                                   rangeForeColor, rangeBackColor));

                spanStart = spanEnd;
            } while (spanStart < text.length());
        } else {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(0, text == null ? 0 : text.length(),
                               GeckoEvent.IME_RANGE_RAWINPUT,
                               GeckoEvent.IME_RANGE_UNDERLINE, 0, 0));
        }

        // Change composition (treating selection end as where the caret is)
        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(mCompositionSelStart + mCompositionSelLen, 0,
                           GeckoEvent.IME_RANGE_CARETPOSITION, 0, 0, 0,
                           mComposingText));

        return true;
    }

    @Override
    public boolean setComposingRegion(int start, int end) {
        //Log.d("GeckoAppJava", "IME: setComposingRegion(start=" + start + ", end=" + end + ")");
        if (start < 0 || end < start)
            return true;

        CharSequence text = null;
        if (start == mCompositionStart && end - start == mComposingText.length()) {
            // Use mComposingText to avoid extra call to Gecko
            text = mComposingText;
        }

        finishComposingText();

        if (text == null && start < end) {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_GET_TEXT, start, end - start));
            try {
                text = mQueryResult.take();
            } catch (InterruptedException e) {
                Log.e("GeckoAppJava", "IME: setComposingRegion interrupted", e);
                return false;
            }
        }

        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_SET_SELECTION, start, end - start));

        // Call setComposingText with the same text to start composition and let Gecko know about new composing region
        setComposingText(text, 1);

        return true;
    }

    @Override
    public boolean setSelection(int start, int end) {
        //Log.d("GeckoAppJava", "IME: setSelection");

        if (mComposing) {
            /* Translate to fake selection positions */
            start -= mCompositionStart;
            end -= mCompositionStart;

            if (start < 0)
                start = 0;
            else if (start > mComposingText.length())
                start = mComposingText.length();

            if (end < 0)
                end = 0;
            else if (end > mComposingText.length())
                end = mComposingText.length();

            mCompositionSelStart = start;
            mCompositionSelLen = end - start;
        } else {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_SET_SELECTION,
                               start, end - start));
        }
        return true;
    }

    public boolean onKeyDel() {
        // Some IMEs don't update us on deletions
        // In that case we are not updated when a composition
        // is destroyed, and Bad Things happen

        if (!mComposing)
            return false;

        if (mComposingText.length() > 0) {
            mComposingText = mComposingText.substring(0,
                mComposingText.length() - 1);
            if (mComposingText.length() > 0)
                return false;
        }

        commitText(null, 1);
        return true;
    }

    public void notifyTextChange(InputMethodManager imm, String text,
                                 int start, int oldEnd, int newEnd) {
        // Log.d("GeckoAppShell", String.format("IME: notifyTextChange: text=%s s=%d ne=%d oe=%d",
        //                                      text, start, newEnd, oldEnd));

        if (mBatchMode) {
            mBatchChanges.add(new ChangeNotification(text, start, oldEnd, newEnd));
            return;
        }

        mNumPendingChanges = Math.max(mNumPendingChanges - 1, 0);

        // If there are pending changes, that means this text is not the most up-to-date version
        // and we'll step on ourselves if we change the editable right now.
        if (mNumPendingChanges == 0 && !text.contentEquals(GeckoApp.surfaceView.mEditable))
            GeckoApp.surfaceView.setEditable(text);

        if (mUpdateRequest == null)
            return;

        mUpdateExtract.flags = 0;

        // We update from (0, oldEnd) to (0, newEnd) because some Android IMEs
        // assume that updates start at zero, according to jchen.
        mUpdateExtract.partialStartOffset = 0;
        mUpdateExtract.partialEndOffset = oldEnd;

        // Faster to not query for selection
        mUpdateExtract.selectionStart = newEnd;
        mUpdateExtract.selectionEnd = newEnd;

        mUpdateExtract.text = text.substring(0, newEnd);
        mUpdateExtract.startOffset = 0;

        imm.updateExtractedText(GeckoApp.surfaceView,
            mUpdateRequest.token, mUpdateExtract);
    }

    public void notifySelectionChange(InputMethodManager imm,
                                      int start, int end) {
        // Log.d("GeckoAppJava", String.format("IME: notifySelectionChange: s=%d e=%d", start, end));
        if (mBatchMode) {
            mBatchChanges.add(new ChangeNotification(start, end));
            return;
        }

        if (mComposing)
            imm.updateSelection(GeckoApp.surfaceView,
                mCompositionStart + mCompositionSelStart,
                mCompositionStart + mCompositionSelStart + mCompositionSelLen,
                mCompositionStart,
                mCompositionStart + mComposingText.length());
        else
            imm.updateSelection(GeckoApp.surfaceView, start, end, -1, -1);

        // We only change the selection if we are relatively sure that the text we have is
        // up-to-date.  Bail out if we are stil expecting changes.
        if (mNumPendingChanges > 0)
            return;

        int maxLen = GeckoApp.surfaceView.mEditable.length();
        Selection.setSelection(GeckoApp.surfaceView.mEditable, 
                               Math.min(start, maxLen),
                               Math.min(end, maxLen));
    }

    public void reset() {
        mComposing = false;
        mComposingText = "";
        mUpdateRequest = null;
        mNumPendingChanges = 0;
        mBatchMode = false;
        mBatchChanges.clear();
    }

    // TextWatcher
    public void onTextChanged(CharSequence s, int start, int before, int count)
    {
        // Log.d("GeckoAppShell", String.format("IME: onTextChanged: t=%s s=%d b=%d l=%d",
        //                                      s, start, before, count));

        mNumPendingChanges++;
        GeckoAppShell.sendEventToGecko(
            new GeckoEvent(GeckoEvent.IME_SET_SELECTION, start, before));

        if (count == 0) {
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_DELETE_TEXT, 0, 0));
        } else {
            // Start and stop composition to force UI updates.
            finishComposingText();
            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_BEGIN, 0, 0));

            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(0, count,
                               GeckoEvent.IME_RANGE_RAWINPUT, 0, 0, 0,
                               s.subSequence(start, start + count).toString()));

            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_COMPOSITION_END, 0, 0));

            GeckoAppShell.sendEventToGecko(
                new GeckoEvent(GeckoEvent.IME_SET_SELECTION, start + count, 0));
        }

        // Block this thread until all pending events are processed
        GeckoAppShell.geckoEventSync();
    }

    public void afterTextChanged(Editable s)
    {
    }

    public void beforeTextChanged(CharSequence s, int start, int count, int after)
    {
    }

    // Is a composition active?
    boolean mComposing;
    // Composition text when a composition is active
    String mComposingText = "";
    // Start index of the composition within the text body
    int mCompositionStart;
    /* During a composition, we should not alter the real selection,
        therefore we keep our own offsets to emulate selection */
    // Start of fake selection, relative to start of composition
    int mCompositionSelStart;
    // Length of fake selection
    int mCompositionSelLen;
    // Number of in flight changes
    int mNumPendingChanges;

    boolean mBatchMode;
    private CopyOnWriteArrayList<ChangeNotification> mBatchChanges =
        new CopyOnWriteArrayList<ChangeNotification>();

    ExtractedTextRequest mUpdateRequest;
    final ExtractedText mUpdateExtract = new ExtractedText();

    int mSelectionStart, mSelectionLength;
    SynchronousQueue<String> mQueryResult;
}

back to top