| // Copyright 2010-2014, Google Inc. |
| // All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are |
| // met: |
| // |
| // * Redistributions of source code must retain the above copyright |
| // notice, this list of conditions and the following disclaimer. |
| // * Redistributions in binary form must reproduce the above |
| // copyright notice, this list of conditions and the following disclaimer |
| // in the documentation and/or other materials provided with the |
| // distribution. |
| // * Neither the name of Google Inc. nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS |
| // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT |
| // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR |
| // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT |
| // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
| // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, |
| // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY |
| // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| package org.mozc.android.inputmethod.japanese.model; |
| |
| import org.mozc.android.inputmethod.japanese.MozcLog; |
| import org.mozc.android.inputmethod.japanese.MozcUtil; |
| import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.DeletionRange; |
| import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Preedit; |
| import org.mozc.android.inputmethod.japanese.protobuf.ProtoCommands.Preedit.Segment; |
| import com.google.common.base.Objects; |
| |
| import android.util.Log; |
| |
| import java.util.ArrayDeque; |
| |
| /** |
| * This class tracks the caret position based on the callback from MozcService. |
| * |
| * In order to reset the context at appropriate timing, we need to find an unknown event |
| * from connected applications. The only way to know that the text field is updated outside |
| * from MozcSerivce is using onUpdateSelection. Unfortunately there is no function to get |
| * the current selection position via InputConnection, we need to track all the caret positions |
| * by calculating them from the result of the mozc server, initial caret positions and |
| * the onUpdateSelection's arguments. |
| * |
| * However, the behavior of the callback doesn't look standardized. Actually it's called |
| * differently from various applications. One of the biggest clients for IME is EditText and |
| * WebTextView widgets (i.e. browsers), but they have also different behaviors... |
| * |
| * This class is introduced to fill the gap as much as possible. |
| * |
| */ |
| public class SelectionTracker { |
| |
| /** |
| * This is a update log by Mozc in order to support onUpdatedSelection. |
| * See details in the comments in {@link SelectionTracker#onUpdateSelection}. |
| */ |
| static class Record { |
| final int candidatesStart; |
| final int candidatesEnd; |
| final int selectionStart; |
| final int selectionEnd; |
| |
| Record(int candidatesStart, int candidatesEnd, int selectionStart, int selectionEnd) { |
| this.candidatesStart = candidatesStart; |
| this.candidatesEnd = candidatesEnd; |
| this.selectionStart = selectionStart; |
| this.selectionEnd = selectionEnd; |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (!(obj instanceof Record)) { |
| return false; |
| } |
| Record other = Record.class.cast(obj); |
| return (candidatesStart == other.candidatesStart) |
| && (candidatesEnd == other.candidatesEnd) |
| && (selectionStart == other.selectionStart) |
| && (selectionEnd == other.selectionEnd); |
| } |
| |
| @Override |
| public String toString() { |
| return String.format("candidates(%d, %d), selection(%d, %d)", |
| candidatesStart, candidatesEnd, selectionStart, selectionEnd); |
| } |
| |
| // Skipped to implement hashCode intentionally, as we don't expect use it. |
| } |
| |
| // In order to keep the update record log small, we'll limit the number of records. |
| // It shouldn't be problem basically, because the onUpdateExtractedText should be frequently |
| // invoked so that the number of records kept in updateRecordList should be small in typical |
| // cases. This also prevents OOM killer due to no-invocation of onUpdateExtractedText |
| // from a connected application. |
| private static final int MAX_UPDATE_RECORD_QUEUE_SIZE = 50; |
| |
| // Return value for the onUpdateSelection. |
| public static final int DO_NOTHING = -1; |
| public static final int RESET_CONTEXT = -2; |
| |
| private final ArrayDeque<Record> recordQueue = |
| new ArrayDeque<Record>(MAX_UPDATE_RECORD_QUEUE_SIZE); |
| private int initialSelectionStart; |
| private int initialSelectionEnd; |
| private boolean webTextView; |
| |
| private void clear() { |
| recordQueue.clear(); |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d("clear: " + toString()); |
| } |
| } |
| |
| private void offerInternal(int candidatesStart, int candidatesEnd, |
| int selectionStart, int selectionEnd) { |
| while (recordQueue.size() >= MAX_UPDATE_RECORD_QUEUE_SIZE) { |
| recordQueue.removeFirst(); |
| } |
| recordQueue.offerLast( |
| new Record(candidatesStart, candidatesEnd, selectionStart, selectionEnd)); |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d("offerInternal: " + toString()); |
| } |
| } |
| |
| public void onStartInput( |
| int initialSelectionStart, int initialSelectionEnd, boolean webTextView) { |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d(String.format("onStartInput: %d %d %b", |
| initialSelectionStart, initialSelectionEnd, webTextView)); |
| } |
| this.webTextView = webTextView; |
| |
| if (initialSelectionStart == -1 && initialSelectionEnd == -1) { |
| // Ignores (-1, -1). |
| // This case can be observed when the IME is not connected to any field. |
| return; |
| } |
| |
| clear(); |
| offerInternal(-1, -1, initialSelectionStart, initialSelectionEnd); |
| |
| this.initialSelectionStart = initialSelectionStart; |
| this.initialSelectionEnd = initialSelectionEnd; |
| } |
| |
| public void onFinishInput() { |
| // The input flow is finished. |
| clear(); |
| } |
| |
| public void onConfigurationChanged() { |
| // The Configuration such as orientation is changed, so we reset the context. |
| clear(); |
| } |
| |
| public void onWindowHidden() { |
| Record last = recordQueue.peekLast(); |
| if (last == null) { |
| return; |
| } |
| |
| // Remember the latest position of the selection (caret). It will be used when the input view |
| // is re-shown, and a user re-starts the input. |
| clear(); |
| offerInternal(-1, -1, last.selectionStart, last.selectionEnd); |
| } |
| |
| public int getLastSelectionStart() { |
| Record record = recordQueue.peekLast(); |
| if (record == null) { |
| return -1; |
| } |
| return record.selectionStart; |
| } |
| |
| public int getLastSelectionEnd() { |
| Record record = recordQueue.peekLast(); |
| if (record == null) { |
| return -1; |
| } |
| return record.selectionEnd; |
| } |
| |
| public int getPreeditStartPosition() { |
| Record record = recordQueue.peekLast(); |
| if (record != null) { |
| // Use candidatesStart, if exists. |
| int candidatesStart = record.candidatesStart; |
| |
| if (candidatesStart == -1) { |
| // If candidatesStart is -1, i.e. we don't have preedit, use the current caret position. |
| candidatesStart = Math.min(record.selectionStart, record.selectionEnd); |
| } |
| |
| // To avoid un-expected case, we guard the result to positive value. |
| if (candidatesStart >= 0) { |
| return candidatesStart; |
| } |
| } |
| |
| // There are no record yet. So we'll use initialSelection as a fallback. |
| { |
| int caretPosition = Math.min(initialSelectionStart, initialSelectionEnd); |
| if (caretPosition >= 0) { |
| return caretPosition; |
| } |
| } |
| |
| // When both are failed, give up to return the correct position. |
| return -1; |
| } |
| |
| /** |
| * Should be invoked when the MozcService sends text to the connected application. |
| */ |
| public void onRender(DeletionRange deletionRange, String commitText, Preedit preedit) { |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d("onRender: " + Objects.firstNonNull(preedit, "").toString()); |
| } |
| int preeditStartPosition = getPreeditStartPosition(); |
| if (deletionRange != null) { |
| // Note that deletionRange#getOffset usually returns negative value. |
| preeditStartPosition += deletionRange.getOffset(); |
| } |
| |
| if (commitText != null) { |
| preeditStartPosition += commitText.length(); |
| } |
| |
| if (preedit == null) { |
| // If we don't render the preedit, just remember the caret position. |
| // It should be at preeditStartPosition. |
| offerInternal(-1, -1, preeditStartPosition, preeditStartPosition); |
| return; |
| } |
| |
| // Here is the most complicated situation. |
| int preeditEndPosition = preeditStartPosition; |
| for (Segment segment : preedit.getSegmentList()) { |
| preeditEndPosition += segment.getValue().length(); |
| } |
| |
| if (webTextView) { |
| // We expect onUpdateSelection invocation with appropriate |
| // newSel{Start,End} and candidates{Start,End} followed by this invocation. |
| // However, web view invokes the callback with unexpected arguments. |
| // The affected application's usage would be so huge (c.f. "browser" is affected), so |
| // we have additional handling for it here. |
| // |
| // For one rendering, web view behavior seems like; |
| // 1) move the caret to the beginning or end point of the previous composition string, |
| // with no composition string (candidatesStart = candidatesEnd = -1). |
| // 2) then, move the caret to appropriate position, with correct composition string info. |
| // As the result, onUpdateSelection is usually invoked twice, with above arguments. |
| // Without any handling, MozcService misunderstands "an unknown event is happen in the |
| // connected application so that it needs to reset itself." |
| // |
| // Unfortunately, it is much difficult to handle above case in onUpdateSelection, because |
| // 1)-callback's arguments are similar to ones of an actual unknown user's event. |
| // So instead, we add a "dummy" update record here, so that 1) event will be known event |
| // and MozcService won't reset itself. |
| // |
| // There is an exceptional case for 1). If the last position is already at the end of the |
| // previous composition string, 1) looks "just a staying caret". So, the 1) will be simply |
| // skipped. Regardless of such exceptional cases, 2)'s event will happen. |
| Record record = recordQueue.peekLast(); |
| if (record != null && |
| (record.selectionStart != record.selectionEnd || |
| record.selectionStart != record.candidatesEnd)) { |
| if (record.candidatesStart == -1) { |
| // If no candidates are available, the right position of selection{Start,End} is |
| // considered as the dummy caret position. |
| int dummyCaretPosition = Math.max(record.selectionStart, record.selectionEnd); |
| offerInternal(-1, -1, dummyCaretPosition, dummyCaretPosition); |
| } else { |
| // Otherwise set the dummy caret position to the begin or end of the candidate |
| // based on the cursor position. |
| int dummyCaretPosition = |
| (preedit.getCursor() <= 0) ? record.candidatesStart : record.candidatesEnd; |
| offerInternal(-1, -1, dummyCaretPosition, dummyCaretPosition); |
| } |
| } |
| } |
| |
| int caretPosition = preeditStartPosition + preedit.getCursor(); |
| offerInternal(preeditStartPosition, preeditEndPosition, caretPosition, caretPosition); |
| } |
| |
| /** |
| * @return true if any record has the same candidate length of given {@code record} |
| */ |
| private boolean containsSeeingOnlyCandidateLength(Record record) { |
| int recoredLength = Math.abs(record.candidatesStart - record.candidatesEnd); |
| for (Record recorded : recordQueue) { |
| if (Math.abs(recorded.candidatesStart - recorded.candidatesEnd) == recoredLength) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| /** |
| * Should be invoked when MozcServer receives the callback {@code onUpdateSelection}. |
| * @return the move cursor position, or one of special values |
| * {@code DO_NOTHING, RESET_CONTEXT}. The caller should follow the result. |
| */ |
| public int onUpdateSelection(int oldSelStart, int oldSelEnd, |
| int newSelStart, int newSelEnd, |
| int candidatesStart, int candidatesEnd) { |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d(String.format("onUpdateSelection: %d %d %d %d %d %d", |
| oldSelStart, oldSelEnd, newSelStart, newSelEnd, |
| candidatesStart, candidatesEnd)); |
| MozcLog.d(recordQueue.toString()); |
| } |
| Record record = new Record(candidatesStart, candidatesEnd, newSelStart, newSelEnd); |
| |
| // There are four cases to come here. |
| // 1) Framework invokes this callback when the caret position is updated due to the text |
| // change from IME, i.e. MozcService. |
| // 2-1) During composition, users can move the caret position by tapping somewhere around the |
| // current preedit text. |
| // 2-2) During composition, users can make a selection region by long-tapping somewhere text. |
| // 3) Unexpected cursor/selection moving coming from outside of MozcService. |
| |
| // At first, we checks 1) state. |
| if (recordQueue.contains(record)) { |
| // This is case 1). Discard preceding records, because on some devices, some callbacks are |
| // just skipped. |
| while (!recordQueue.isEmpty()) { |
| Record head = recordQueue.peekFirst(); |
| if (record.equals(head)) { |
| // Note: keep the last record. |
| break; |
| } |
| recordQueue.removeFirst(); |
| } |
| return DO_NOTHING; |
| } |
| |
| // Here, the event is not caused by MozcService (probably). |
| Record lastRecord = recordQueue.peekLast(); |
| if (lastRecord != null && |
| lastRecord.candidatesStart >= 0 && |
| lastRecord.candidatesStart == candidatesStart && |
| lastRecord.candidatesEnd == candidatesEnd) { |
| // This is the case 2). |
| // Remember the new position. |
| clear(); |
| offerInternal(candidatesStart, candidatesEnd, newSelStart, newSelEnd); |
| if (newSelStart == newSelEnd) { |
| // This is case 2-1) |
| // In composition with tapping somewhere. |
| return MozcUtil.clamp(newSelStart - candidatesStart, 0, candidatesEnd - candidatesStart); |
| } |
| |
| // This is case 2-2). |
| // Commit the composing text and reset the context. So that, in the next turn, |
| // the user can edit the selected region as usual. |
| return RESET_CONTEXT; |
| } |
| |
| // Here is the case 3), i.e. totally unknown state. |
| // This can happen, e.g., |
| // - the cursor is moved when there are no preedit |
| // - the text message is sent to the chat by tapping sending button |
| // - the field is filled by the application's suggestion |
| // Thus, we reset the context. |
| // But on problematic views, which don't call onUpdateSelection when there is not preedit |
| // (e.g. WebView), execution flow reaches here unexpectedly. |
| // In such case the context is reset unexpectedly, which causes serious unpleasantness. |
| // Therefore fall-back logic is implemented here. |
| // If any recorded entry has given candidate length (candidatesEnd - candidatesStart), |
| // reset the queue and return DO_NOTHING instead. Such recored entry was recorded in |
| // previous call of onRender. |
| // For example on problematic views following scenario would be seen. |
| // - onRender (commit) |
| // - onUpdateSelection (caused by last commit) |
| // - undetectable cursor move (causes record inconsistency) |
| // - onRender (records invalid entry but its length is correct) |
| // - onUpdateSelection (here) |
| // - Records are basically unreliable but the last one has correct length) |
| if (candidatesStart != -1 || candidatesEnd != -1) { |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d("Unknown candidates: " + candidatesStart + ":" + candidatesEnd); |
| } |
| if (webTextView && containsSeeingOnlyCandidateLength(record)) { |
| if (MozcLog.isLoggable(Log.DEBUG)) { |
| MozcLog.d(String.format( |
| "Fall-back is applied as " + |
| "there is a entry of which the candidate length (%d) meets expectation.", |
| candidatesEnd - candidatesStart)); |
| } |
| clear(); |
| offerInternal(candidatesStart, candidatesEnd, newSelStart, newSelEnd); |
| return DO_NOTHING; |
| } |
| } |
| |
| // For the next handling, we should remember the newest position. |
| clear(); |
| offerInternal(-1, -1, newSelStart, newSelEnd); |
| |
| // Tell the caller to reset the context. |
| return RESET_CONTEXT; |
| } |
| |
| @Override |
| public String toString() { |
| return Objects.toStringHelper(this) |
| .add("recordQueue", recordQueue) |
| .add("initialSelectionStart", initialSelectionStart) |
| .add("initialSelectionEnd", initialSelectionEnd) |
| .add("webTextView", webTextView) |
| .toString(); |
| } |
| } |