RichInputConnection.java revision d579f1aefc8d02254db297ffd6d8f9dbdcab0637
1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5 * use this file except in compliance with the License. You may obtain a copy of
6 * the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 * License for the specific language governing permissions and limitations under
14 * the License.
15 */
16
17package com.android.inputmethod.latin;
18
19import android.inputmethodservice.InputMethodService;
20import android.text.TextUtils;
21import android.util.Log;
22import android.view.KeyEvent;
23import android.view.inputmethod.CompletionInfo;
24import android.view.inputmethod.CorrectionInfo;
25import android.view.inputmethod.ExtractedText;
26import android.view.inputmethod.ExtractedTextRequest;
27import android.view.inputmethod.InputConnection;
28
29import com.android.inputmethod.keyboard.Keyboard;
30import com.android.inputmethod.latin.define.ProductionFlag;
31
32import java.util.regex.Pattern;
33
34/**
35 * Wrapper for InputConnection to simplify interaction
36 */
37public class RichInputConnection {
38    private static final String TAG = RichInputConnection.class.getSimpleName();
39    private static final boolean DBG = false;
40    // Provision for a long word pair and a separator
41    private static final int LOOKBACK_CHARACTER_NUM = BinaryDictionary.MAX_WORD_LENGTH * 2 + 1;
42    private static final Pattern spaceRegex = Pattern.compile("\\s+");
43    private static final int INVALID_CURSOR_POSITION = -1;
44
45    private final InputMethodService mParent;
46    InputConnection mIC;
47    int mNestLevel;
48    public RichInputConnection(final InputMethodService parent) {
49        mParent = parent;
50        mIC = null;
51        mNestLevel = 0;
52    }
53
54    public void beginBatchEdit() {
55        if (++mNestLevel == 1) {
56            mIC = mParent.getCurrentInputConnection();
57            if (null != mIC) mIC.beginBatchEdit();
58        } else {
59            if (DBG) {
60                throw new RuntimeException("Nest level too deep");
61            } else {
62                Log.e(TAG, "Nest level too deep : " + mNestLevel);
63            }
64        }
65    }
66    public void endBatchEdit() {
67        if (mNestLevel <= 0) Log.e(TAG, "Batch edit not in progress!"); // TODO: exception instead
68        if (--mNestLevel == 0 && null != mIC) mIC.endBatchEdit();
69    }
70
71    private void checkBatchEdit() {
72        if (mNestLevel != 1) {
73            // TODO: exception instead
74            Log.e(TAG, "Batch edit level incorrect : " + mNestLevel);
75            Log.e(TAG, Utils.getStackTrace(4));
76        }
77    }
78
79    public void finishComposingText() {
80        checkBatchEdit();
81        if (null != mIC) mIC.finishComposingText();
82    }
83
84    public void commitText(final CharSequence text, final int i) {
85        checkBatchEdit();
86        if (null != mIC) mIC.commitText(text, i);
87    }
88
89    public int getCursorCapsMode(final int inputType) {
90        mIC = mParent.getCurrentInputConnection();
91        if (null == mIC) return Constants.TextUtils.CAP_MODE_OFF;
92        return mIC.getCursorCapsMode(inputType);
93    }
94
95    public CharSequence getTextBeforeCursor(final int i, final int j) {
96        mIC = mParent.getCurrentInputConnection();
97        if (null != mIC) return mIC.getTextBeforeCursor(i, j);
98        return null;
99    }
100
101    public CharSequence getTextAfterCursor(final int i, final int j) {
102        mIC = mParent.getCurrentInputConnection();
103        if (null != mIC) return mIC.getTextAfterCursor(i, j);
104        return null;
105    }
106
107    public void deleteSurroundingText(final int i, final int j) {
108        checkBatchEdit();
109        if (null != mIC) mIC.deleteSurroundingText(i, j);
110    }
111
112    public void performEditorAction(final int actionId) {
113        mIC = mParent.getCurrentInputConnection();
114        if (null != mIC) mIC.performEditorAction(actionId);
115    }
116
117    public void sendKeyEvent(final KeyEvent keyEvent) {
118        checkBatchEdit();
119        if (null != mIC) mIC.sendKeyEvent(keyEvent);
120    }
121
122    public void setComposingText(final CharSequence text, final int i) {
123        checkBatchEdit();
124        if (null != mIC) mIC.setComposingText(text, i);
125    }
126
127    public void setSelection(final int from, final int to) {
128        checkBatchEdit();
129        if (null != mIC) mIC.setSelection(from, to);
130    }
131
132    public void commitCorrection(final CorrectionInfo correctionInfo) {
133        checkBatchEdit();
134        if (null != mIC) mIC.commitCorrection(correctionInfo);
135    }
136
137    public void commitCompletion(final CompletionInfo completionInfo) {
138        checkBatchEdit();
139        if (null != mIC) mIC.commitCompletion(completionInfo);
140    }
141
142    public CharSequence getNthPreviousWord(final String sentenceSeperators, final int n) {
143        mIC = mParent.getCurrentInputConnection();
144        if (null == mIC) return null;
145        final CharSequence prev = mIC.getTextBeforeCursor(LOOKBACK_CHARACTER_NUM, 0);
146        return getNthPreviousWord(prev, sentenceSeperators, n);
147    }
148
149    /**
150     * Represents a range of text, relative to the current cursor position.
151     */
152    public static class Range {
153        /** Characters before selection start */
154        public final int mCharsBefore;
155
156        /**
157         * Characters after selection start, including one trailing word
158         * separator.
159         */
160        public final int mCharsAfter;
161
162        /** The actual characters that make up a word */
163        public final String mWord;
164
165        public Range(int charsBefore, int charsAfter, String word) {
166            if (charsBefore < 0 || charsAfter < 0) {
167                throw new IndexOutOfBoundsException();
168            }
169            this.mCharsBefore = charsBefore;
170            this.mCharsAfter = charsAfter;
171            this.mWord = word;
172        }
173    }
174
175    private static boolean isSeparator(int code, String sep) {
176        return sep.indexOf(code) != -1;
177    }
178
179    // Get the nth word before cursor. n = 1 retrieves the word immediately before the cursor,
180    // n = 2 retrieves the word before that, and so on. This splits on whitespace only.
181    // Also, it won't return words that end in a separator (if the nth word before the cursor
182    // ends in a separator, it returns null).
183    // Example :
184    // (n = 1) "abc def|" -> def
185    // (n = 1) "abc def |" -> def
186    // (n = 1) "abc def. |" -> null
187    // (n = 1) "abc def . |" -> null
188    // (n = 2) "abc def|" -> abc
189    // (n = 2) "abc def |" -> abc
190    // (n = 2) "abc def. |" -> abc
191    // (n = 2) "abc def . |" -> def
192    // (n = 2) "abc|" -> null
193    // (n = 2) "abc |" -> null
194    // (n = 2) "abc. def|" -> null
195    public static CharSequence getNthPreviousWord(final CharSequence prev,
196            final String sentenceSeperators, final int n) {
197        if (prev == null) return null;
198        String[] w = spaceRegex.split(prev);
199
200        // If we can't find n words, or we found an empty word, return null.
201        if (w.length < n || w[w.length - n].length() <= 0) return null;
202
203        // If ends in a separator, return null
204        char lastChar = w[w.length - n].charAt(w[w.length - n].length() - 1);
205        if (sentenceSeperators.contains(String.valueOf(lastChar))) return null;
206
207        return w[w.length - n];
208    }
209
210    /**
211     * @param separators characters which may separate words
212     * @return the word that surrounds the cursor, including up to one trailing
213     *   separator. For example, if the field contains "he|llo world", where |
214     *   represents the cursor, then "hello " will be returned.
215     */
216    public String getWordAtCursor(String separators) {
217        // getWordRangeAtCursor returns null if the connection is null
218        Range r = getWordRangeAtCursor(separators, 0);
219        return (r == null) ? null : r.mWord;
220    }
221
222    private int getCursorPosition() {
223        mIC = mParent.getCurrentInputConnection();
224        if (null == mIC) return INVALID_CURSOR_POSITION;
225        final ExtractedText extracted = mIC.getExtractedText(new ExtractedTextRequest(), 0);
226        if (extracted == null) {
227            return INVALID_CURSOR_POSITION;
228        }
229        return extracted.startOffset + extracted.selectionStart;
230    }
231
232    /**
233     * Returns the text surrounding the cursor.
234     *
235     * @param sep a string of characters that split words.
236     * @param additionalPrecedingWordsCount the number of words before the current word that should
237     *   be included in the returned range
238     * @return a range containing the text surrounding the cursor
239     */
240    public Range getWordRangeAtCursor(String sep, int additionalPrecedingWordsCount) {
241        mIC = mParent.getCurrentInputConnection();
242        if (mIC == null || sep == null) {
243            return null;
244        }
245        CharSequence before = mIC.getTextBeforeCursor(1000, 0);
246        CharSequence after = mIC.getTextAfterCursor(1000, 0);
247        if (before == null || after == null) {
248            return null;
249        }
250
251        // Going backward, alternate skipping non-separators and separators until enough words
252        // have been read.
253        int start = before.length();
254        boolean isStoppingAtWhitespace = true;  // toggles to indicate what to stop at
255        while (true) { // see comments below for why this is guaranteed to halt
256            while (start > 0) {
257                final int codePoint = Character.codePointBefore(before, start);
258                if (isStoppingAtWhitespace == isSeparator(codePoint, sep)) {
259                    break;  // inner loop
260                }
261                --start;
262                if (Character.isSupplementaryCodePoint(codePoint)) {
263                    --start;
264                }
265            }
266            // isStoppingAtWhitespace is true every other time through the loop,
267            // so additionalPrecedingWordsCount is guaranteed to become < 0, which
268            // guarantees outer loop termination
269            if (isStoppingAtWhitespace && (--additionalPrecedingWordsCount < 0)) {
270                break;  // outer loop
271            }
272            isStoppingAtWhitespace = !isStoppingAtWhitespace;
273        }
274
275        // Find last word separator after the cursor
276        int end = -1;
277        while (++end < after.length()) {
278            final int codePoint = Character.codePointAt(after, end);
279            if (isSeparator(codePoint, sep)) {
280                break;
281            }
282            if (Character.isSupplementaryCodePoint(codePoint)) {
283                ++end;
284            }
285        }
286
287        int cursor = getCursorPosition();
288        if (start >= 0 && cursor + end <= after.length() + before.length()) {
289            String word = before.toString().substring(start, before.length())
290                    + after.toString().substring(0, end);
291            return new Range(before.length() - start, end, word);
292        }
293
294        return null;
295    }
296
297    public boolean isCursorTouchingWord(final SettingsValues settingsValues) {
298        CharSequence before = getTextBeforeCursor(1, 0);
299        CharSequence after = getTextAfterCursor(1, 0);
300        if (!TextUtils.isEmpty(before) && !settingsValues.isWordSeparator(before.charAt(0))
301                && !settingsValues.isSymbolExcludedFromWordSeparators(before.charAt(0))) {
302            return true;
303        }
304        if (!TextUtils.isEmpty(after) && !settingsValues.isWordSeparator(after.charAt(0))
305                && !settingsValues.isSymbolExcludedFromWordSeparators(after.charAt(0))) {
306            return true;
307        }
308        return false;
309    }
310
311    public void removeTrailingSpace() {
312        checkBatchEdit();
313        final CharSequence lastOne = getTextBeforeCursor(1, 0);
314        if (lastOne != null && lastOne.length() == 1
315                && lastOne.charAt(0) == Keyboard.CODE_SPACE) {
316            deleteSurroundingText(1, 0);
317            if (ProductionFlag.IS_EXPERIMENTAL) {
318                ResearchLogger.latinIME_deleteSurroundingText(1);
319            }
320        }
321    }
322
323    public boolean sameAsTextBeforeCursor(final CharSequence text) {
324        final CharSequence beforeText = getTextBeforeCursor(text.length(), 0);
325        return TextUtils.equals(text, beforeText);
326    }
327
328    /* (non-javadoc)
329     * Returns the word before the cursor if the cursor is at the end of a word, null otherwise
330     */
331    public CharSequence getWordBeforeCursorIfAtEndOfWord(final SettingsValues settings) {
332        // Bail out if the cursor is in the middle of a word (cursor must be followed by whitespace,
333        // separator or end of line/text)
334        // Example: "test|"<EOL> "te|st" get rejected here
335        final CharSequence textAfterCursor = getTextAfterCursor(1, 0);
336        if (!TextUtils.isEmpty(textAfterCursor)
337                && !settings.isWordSeparator(textAfterCursor.charAt(0))) return null;
338
339        // Bail out if word before cursor is 0-length or a single non letter (like an apostrophe)
340        // Example: " -|" gets rejected here but "e-|" and "e|" are okay
341        CharSequence word = getWordAtCursor(settings.mWordSeparators);
342        // We don't suggest on leading single quotes, so we have to remove them from the word if
343        // it starts with single quotes.
344        while (!TextUtils.isEmpty(word) && Keyboard.CODE_SINGLE_QUOTE == word.charAt(0)) {
345            word = word.subSequence(1, word.length());
346        }
347        if (TextUtils.isEmpty(word)) return null;
348        // Find the last code point of the string
349        final int lastCodePoint = Character.codePointBefore(word, word.length());
350        // If for some reason the text field contains non-unicode binary data, or if the
351        // charsequence is exactly one char long and the contents is a low surrogate, return null.
352        if (!Character.isDefined(lastCodePoint)) return null;
353        // Bail out if the cursor is not at the end of a word (cursor must be preceded by
354        // non-whitespace, non-separator, non-start-of-text)
355        // Example ("|" is the cursor here) : <SOL>"|a" " |a" " | " all get rejected here.
356        if (settings.isWordSeparator(lastCodePoint)) return null;
357        final char firstChar = word.charAt(0); // we just tested that word is not empty
358        if (word.length() == 1 && !Character.isLetter(firstChar)) return null;
359
360        // We only suggest on words that start with a letter or a symbol that is excluded from
361        // word separators (see #handleCharacterWhileInBatchEdit).
362        if (!(Character.isLetter(firstChar)
363                || settings.isSymbolExcludedFromWordSeparators(firstChar))) {
364            return null;
365        }
366
367        return word;
368    }
369
370    public boolean revertDoubleSpace() {
371        checkBatchEdit();
372        // Here we test whether we indeed have a period and a space before us. This should not
373        // be needed, but it's there just in case something went wrong.
374        final CharSequence textBeforeCursor = getTextBeforeCursor(2, 0);
375        if (!". ".equals(textBeforeCursor)) {
376            // Theoretically we should not be coming here if there isn't ". " before the
377            // cursor, but the application may be changing the text while we are typing, so
378            // anything goes. We should not crash.
379            Log.d(TAG, "Tried to revert double-space combo but we didn't find "
380                    + "\". \" just before the cursor.");
381            return false;
382        }
383        deleteSurroundingText(2, 0);
384        if (ProductionFlag.IS_EXPERIMENTAL) {
385            ResearchLogger.latinIME_deleteSurroundingText(2);
386        }
387        commitText("  ", 1);
388        if (ProductionFlag.IS_EXPERIMENTAL) {
389            ResearchLogger.latinIME_revertDoubleSpaceWhileInBatchEdit();
390        }
391        return true;
392    }
393
394    public boolean revertSwapPunctuation() {
395        checkBatchEdit();
396        // Here we test whether we indeed have a space and something else before us. This should not
397        // be needed, but it's there just in case something went wrong.
398        final CharSequence textBeforeCursor = getTextBeforeCursor(2, 0);
399        // NOTE: This does not work with surrogate pairs. Hopefully when the keyboard is able to
400        // enter surrogate pairs this code will have been removed.
401        if (TextUtils.isEmpty(textBeforeCursor)
402                || (Keyboard.CODE_SPACE != textBeforeCursor.charAt(1))) {
403            // We may only come here if the application is changing the text while we are typing.
404            // This is quite a broken case, but not logically impossible, so we shouldn't crash,
405            // but some debugging log may be in order.
406            Log.d(TAG, "Tried to revert a swap of punctuation but we didn't "
407                    + "find a space just before the cursor.");
408            return false;
409        }
410        deleteSurroundingText(2, 0);
411        if (ProductionFlag.IS_EXPERIMENTAL) {
412            ResearchLogger.latinIME_deleteSurroundingText(2);
413        }
414        commitText(" " + textBeforeCursor.subSequence(0, 1), 1);
415        if (ProductionFlag.IS_EXPERIMENTAL) {
416            ResearchLogger.latinIME_revertSwapPunctuation();
417        }
418        return true;
419    }
420}
421