blob: 93fc80d2680ce8f231e613818a43bcb189348a13 [file] [log] [blame]
/*
* Copyright (C) 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 com.android.inputmethod.leanback;
import android.graphics.PointF;
import android.inputmethodservice.InputMethodService;
import android.inputmethodservice.Keyboard;
import android.inputmethodservice.Keyboard.Key;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import com.android.inputmethod.leanback.LeanbackKeyboardContainer.KeyFocus;
import java.util.ArrayList;
/**
* Holds logic for the keyboard views. This includes things like when to
* snap, when to switch keyboards, etc. It provides callbacks for when actions
* that need to be handled at the IME level occur (when text is entered, when
* the action should be performed).
*/
public class LeanbackKeyboardController implements LeanbackKeyboardContainer.VoiceListener,
LeanbackKeyboardContainer.DismissListener {
private static final String TAG = "LbKbController";
private static final boolean DEBUG = false;
/**
* The amount of time to block movement after a button down was detected.
*/
public static final int CLICK_MOVEMENT_BLOCK_DURATION_MS = 500;
/**
* The minimum distance in pixels before the view will transition to the
* move state.
*/
public float mResizeSquareDistance;
// keep track of the most recent key changes and their times so we can
// revert motion caused by clicking
private static final int KEY_CHANGE_HISTORY_SIZE = 10;
private static final long KEY_CHANGE_REVERT_TIME_MS = 100;
/**
* This listener reports high level actions that have occurred, such as
* text entry (from keys or voice) or the action button being pressed.
*/
public interface InputListener {
public static final int ENTRY_TYPE_STRING = 0;
public static final int ENTRY_TYPE_BACKSPACE = 1;
public static final int ENTRY_TYPE_SUGGESTION = 2;
public static final int ENTRY_TYPE_LEFT = 3;
public static final int ENTRY_TYPE_RIGHT = 4;
public static final int ENTRY_TYPE_ACTION = 5;
public static final int ENTRY_TYPE_VOICE = 6;
public static final int ENTRY_TYPE_DISMISS = 7;
public static final int ENTRY_TYPE_VOICE_DISMISS = 8;
/**
* Sent when the user has selected something that should affect the text
* field, such as entering a character, selecting the action, or
* finishing a voice action.
*
* @param type The type of key selected
* @param keyCode the key code of the key if applicable
* @param result The text entered if applicable
*/
public void onEntry(int type, int keyCode, CharSequence result);
}
private static final class KeyChange {
public long time;
public PointF position;
public KeyChange(long time, PointF position) {
this.time = time;
this.position = position;
}
}
private class DoubleClickDetector {
final long DOUBLE_CLICK_TIMEOUT_MS = 200;
long mFirstClickTime = 0;
boolean mFirstClickShiftLocked;
public void reset() {
mFirstClickTime = 0;
}
public void addEvent(long currTime) {
if (currTime - mFirstClickTime > DOUBLE_CLICK_TIMEOUT_MS) {
mFirstClickTime = currTime;
mFirstClickShiftLocked = mContainer.isCapsLockOn();
commitKey();
} else {
mContainer.onShiftDoubleClick(mFirstClickShiftLocked);
reset();
}
}
}
private DoubleClickDetector mDoubleClickDetector = new DoubleClickDetector();
private View.OnLayoutChangeListener mOnLayoutChangeListener
= new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
int w = right - left;
int h = bottom - top;
int oldW = oldRight - oldLeft;
int oldH = oldBottom - oldTop;
if (w > 0 && h > 0) {
if (w != oldW || h != oldH) {
initInputView();
}
}
}
};
private InputMethodService mContext;
private InputListener mInputListener;
private LeanbackKeyboardContainer mContainer;
private LeanbackKeyboardContainer.KeyFocus mDownFocus =
new LeanbackKeyboardContainer.KeyFocus();
private LeanbackKeyboardContainer.KeyFocus mTempFocus =
new LeanbackKeyboardContainer.KeyFocus();
ArrayList<KeyChange> mKeyChangeHistory = new ArrayList<KeyChange>(KEY_CHANGE_HISTORY_SIZE + 1);
private PointF mTempPoint = new PointF();
private boolean mKeyDownReceived = false;
private boolean mLongPressHandled = false;
private KeyFocus mKeyDownKeyFocus;
private int mMoveCount;
public LeanbackKeyboardController(InputMethodService context, InputListener listener) {
this(context, listener, new LeanbackKeyboardContainer(context));
}
LeanbackKeyboardController(InputMethodService context, InputListener listener,
LeanbackKeyboardContainer container) {
mContext = context;
mResizeSquareDistance = context.getResources().getDimension(R.dimen.resize_move_distance);
mResizeSquareDistance *= mResizeSquareDistance;
mInputListener = listener;
setKeyboardContainer(container);
mContainer.setVoiceListener(this);
mContainer.setDismissListener(this);
}
/**
* This method is called when we start the input at a NEW input field.
*/
public void onStartInput(EditorInfo attribute) {
if (mContainer != null) {
mContainer.onStartInput(attribute);
initInputView();
}
}
/**
* This method is called by whenever we bring up the IME at an input field.
*/
public void onStartInputView() {
mKeyDownReceived = false;
if (mContainer != null) {
mContainer.onStartInputView();
}
mDoubleClickDetector.reset();
}
/**
* This method sets the pixel positions in mSpaceTracker to match the
* current KeyFocus in {@link LeanbackKeyboardContainer} This method is called
* when the keyboard layout is complete, after
* {@link LeanbackKeyboardContainer.onInitInputView}, to initialize the starting
* position of mSpaceTracker; and in onUp to reset the pixel position in
* mSpaceTracker.
*/
private void updatePositionToCurrentFocus() {
PointF currPosition = getCurrentKeyPosition();
if (currPosition != null) {
}
}
private void initInputView() {
mContainer.onInitInputView();
updatePositionToCurrentFocus();
}
private PointF getCurrentKeyPosition() {
if (mContainer != null) {
LeanbackKeyboardContainer.KeyFocus initialKeyInfo = mContainer.getCurrFocus();
return new PointF(initialKeyInfo.rect.centerX(), initialKeyInfo.rect.centerY());
}
return null;
}
private void performBestSnap(long time) {
KeyFocus focus = mContainer.getCurrFocus();
mTempPoint.x = focus.rect.centerX();
mTempPoint.y = focus.rect.centerY();
PointF bestSnap = getBestSnapPosition(mTempPoint, time);
mContainer.getBestFocus(bestSnap.x, bestSnap.y, mTempFocus);
mContainer.setFocus(mTempFocus);
updatePositionToCurrentFocus();
}
private PointF getBestSnapPosition(PointF currPoint, long currTime) {
if (mKeyChangeHistory.size() <= 1) {
return currPoint;
}
for (int i = 0; i < mKeyChangeHistory.size() - 1; i++) {
KeyChange change = mKeyChangeHistory.get(i);
KeyChange nextChange = mKeyChangeHistory.get(i + 1);
if (currTime - nextChange.time < KEY_CHANGE_REVERT_TIME_MS) {
if (DEBUG) {
Log.d(TAG, "Reverting keychange to " + change.position.toString());
}
// Return the oldest key change within the revert window and
// clear all key changes
currPoint = change.position;
// on a revert, clear the history and add the reverting point.
// This way the reverted point will be preferred if there's
// another fast change before the next call.
mKeyChangeHistory.clear();
mKeyChangeHistory.add(new KeyChange(currTime, currPoint));
break;
}
}
return currPoint;
}
public void setKeyboardContainer(LeanbackKeyboardContainer container) {
mContainer = container;
container.getView().addOnLayoutChangeListener(mOnLayoutChangeListener);
}
public View getView() {
if (mContainer != null) {
return mContainer.getView();
}
return null;
}
public boolean areSuggestionsEnabled() {
if (mContainer != null) {
return mContainer.areSuggestionsEnabled();
}
return false;
}
public boolean enableAutoEnterSpace() {
if (mContainer != null) {
return mContainer.enableAutoEnterSpace();
}
return false;
}
public boolean onKeyDown(int keyCode, KeyEvent event) {
mDownFocus.set(mContainer.getCurrFocus());
// this will handle other events, e.g. hardware keyboard
if (isEnterKey(keyCode)) {
mKeyDownReceived = true;
// first keyDown
if (event.getRepeatCount() == 0) {
mContainer.setTouchState(LeanbackKeyboardContainer.TOUCH_STATE_CLICK);
}
}
return handleKeyDownEvent(keyCode, event.getRepeatCount());
}
public boolean onKeyUp(int keyCode, KeyEvent event) {
// this only handles InputDevice.SOURCE_TOUCH_NAVIGATION events
if (isEnterKey(keyCode)) {
if (!mKeyDownReceived || mLongPressHandled) {
mLongPressHandled = false;
return true;
}
mKeyDownReceived = false;
if (mContainer.getTouchState() == LeanbackKeyboardContainer.TOUCH_STATE_CLICK) {
mContainer.setTouchState(LeanbackKeyboardContainer.TOUCH_STATE_TOUCH_SNAP);
}
}
return handleKeyUpEvent(keyCode, event.getEventTime());
}
public boolean onGenericMotionEvent(MotionEvent event) {
return false;
}
private boolean onDirectionalMove(int dir) {
if (mContainer.getNextFocusInDirection(dir, mDownFocus, mTempFocus)) {
mContainer.setFocus(mTempFocus);
mDownFocus.set(mTempFocus);
clearKeyIfNecessary();
}
return true;
}
private void clearKeyIfNecessary() {
mMoveCount++;
if (mMoveCount >= 3) {
mMoveCount = 0;
mKeyDownKeyFocus = null;
}
}
private void commitKey() {
commitKey(mContainer.getCurrFocus());
}
private void commitKey(LeanbackKeyboardContainer.KeyFocus keyFocus) {
if (mContainer == null || keyFocus == null) {
return;
}
switch (keyFocus.type) {
case KeyFocus.TYPE_VOICE:
// voice doesn't have to go through the IME
mContainer.onVoiceClick();
break;
case KeyFocus.TYPE_ACTION:
mInputListener.onEntry(InputListener.ENTRY_TYPE_ACTION, 0, null);
break;
case KeyFocus.TYPE_SUGGESTION:
mInputListener.onEntry(InputListener.ENTRY_TYPE_SUGGESTION, 0,
mContainer.getSuggestionText(keyFocus.index));
break;
default:
Key key = mContainer.getKey(keyFocus.type, keyFocus.index);
if (key != null) {
int code = key.codes[0];
CharSequence label = key.label;
handleCommitKeyboardKey(code, label);
}
break;
}
}
private void handleCommitKeyboardKey(int code, CharSequence label) {
switch (code) {
case Keyboard.KEYCODE_MODE_CHANGE:
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.d(TAG, "mode change");
}
mContainer.onModeChangeClick();
break;
case LeanbackKeyboardView.KEYCODE_CAPS_LOCK:
mContainer.onShiftDoubleClick(mContainer.isCapsLockOn());
break;
case Keyboard.KEYCODE_SHIFT:
// TODO invalidate and draw a different shift
// key in the function keyboard
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.d(TAG, "shift");
}
mContainer.onShiftClick();
break;
case LeanbackKeyboardView.KEYCODE_DISMISS_MINI_KEYBOARD:
mContainer.dismissMiniKeyboard();
break;
case LeanbackKeyboardView.KEYCODE_LEFT:
mInputListener.onEntry(InputListener.ENTRY_TYPE_LEFT, 0, null);
break;
case LeanbackKeyboardView.KEYCODE_RIGHT:
mInputListener.onEntry(InputListener.ENTRY_TYPE_RIGHT, 0, null);
break;
case Keyboard.KEYCODE_DELETE:
mInputListener.onEntry(InputListener.ENTRY_TYPE_BACKSPACE, 0, null);
break;
case LeanbackKeyboardView.ASCII_SPACE:
mInputListener.onEntry(InputListener.ENTRY_TYPE_STRING, code, " ");
mContainer.onSpaceEntry();
break;
case LeanbackKeyboardView.ASCII_PERIOD:
mInputListener.onEntry(InputListener.ENTRY_TYPE_STRING, code, label);
mContainer.onPeriodEntry();
break;
case LeanbackKeyboardView.KEYCODE_VOICE:
mContainer.startVoiceRecording();
break;
// fall through to default with this label
default:
mInputListener.onEntry(InputListener.ENTRY_TYPE_STRING, code, label);
mContainer.onTextEntry();
if (mContainer.isMiniKeyboardOnScreen()) {
mContainer.dismissMiniKeyboard();
}
break;
}
}
private boolean handleKeyDownEvent(int keyCode, int eventRepeatCount) {
keyCode = getSimplifiedKey(keyCode);
// never trap back
if (keyCode == KeyEvent.KEYCODE_BACK) {
mContainer.cancelVoiceRecording();
return false;
}
// capture all key downs when voice is visible
if (mContainer.isVoiceVisible()) {
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT || keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
mContainer.cancelVoiceRecording();
}
return true;
}
boolean handled = true;
switch(keyCode) {
// Direction keys are handled on down to allow repeated movement
case KeyEvent.KEYCODE_DPAD_LEFT:
handled = onDirectionalMove(LeanbackKeyboardContainer.DIRECTION_LEFT);
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
handled = onDirectionalMove(LeanbackKeyboardContainer.DIRECTION_RIGHT);
break;
case KeyEvent.KEYCODE_DPAD_UP:
handled = onDirectionalMove(LeanbackKeyboardContainer.DIRECTION_UP);
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
handled = onDirectionalMove(LeanbackKeyboardContainer.DIRECTION_DOWN);
break;
case KeyEvent.KEYCODE_BUTTON_X:
handleCommitKeyboardKey(Keyboard.KEYCODE_DELETE, null);
break;
case KeyEvent.KEYCODE_BUTTON_Y:
handleCommitKeyboardKey(LeanbackKeyboardView.ASCII_SPACE, null);
break;
case KeyEvent.KEYCODE_BUTTON_L1:
handleCommitKeyboardKey(LeanbackKeyboardView.KEYCODE_LEFT, null);
break;
case KeyEvent.KEYCODE_BUTTON_R1:
handleCommitKeyboardKey(LeanbackKeyboardView.KEYCODE_RIGHT, null);
break;
// these are handled on up
case KeyEvent.KEYCODE_DPAD_CENTER:
if (eventRepeatCount == 0) {
mMoveCount = 0;
mKeyDownKeyFocus = new KeyFocus();
mKeyDownKeyFocus.set(mContainer.getCurrFocus());
} else if (eventRepeatCount == 1) {
if (handleKeyLongPress(keyCode)) {
mKeyDownKeyFocus = null;
}
}
if (isKeyHandledOnKeyDown(mContainer.getCurrKeyCode())) {
commitKey();
}
break;
// also handled on up
case KeyEvent.KEYCODE_BUTTON_THUMBL:
case KeyEvent.KEYCODE_BUTTON_THUMBR:
case KeyEvent.KEYCODE_ENTER:
break;
default:
handled = false;
break;
}
return handled;
}
private boolean handleKeyLongPress(int keyCode) {
mLongPressHandled = isEnterKey(keyCode) && mContainer.onKeyLongPress();
if (mContainer.isMiniKeyboardOnScreen()) {
Log.d(TAG, "mini keyboard shown after long press");
}
return mLongPressHandled;
}
private boolean isKeyHandledOnKeyDown(int currKeyCode) {
return currKeyCode == Keyboard.KEYCODE_DELETE
|| currKeyCode == LeanbackKeyboardView.KEYCODE_LEFT
|| currKeyCode == LeanbackKeyboardView.KEYCODE_RIGHT;
}
/**
* This handles all key events from an input device
* @param keyCode
* @return true if the key was handled, false otherwise
*/
private boolean handleKeyUpEvent(int keyCode, long currTime) {
keyCode = getSimplifiedKey(keyCode);
// never trap back
if (keyCode == KeyEvent.KEYCODE_BACK) {
return false;
}
// capture all key ups when voice is visible
if (mContainer.isVoiceVisible()) {
return true;
}
boolean handled = true;
switch(keyCode) {
// Some keys are handled on down to allow repeats
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_DOWN:
clearKeyIfNecessary();
break;
case KeyEvent.KEYCODE_BUTTON_X:
case KeyEvent.KEYCODE_BUTTON_Y:
case KeyEvent.KEYCODE_BUTTON_L1:
case KeyEvent.KEYCODE_BUTTON_R1:
break;
case KeyEvent.KEYCODE_DPAD_CENTER:
if (mContainer.getCurrKeyCode() == Keyboard.KEYCODE_SHIFT) {
mDoubleClickDetector.addEvent(currTime);
} else if (!isKeyHandledOnKeyDown(mContainer.getCurrKeyCode())) {
commitKey(mKeyDownKeyFocus);
}
break;
case KeyEvent.KEYCODE_BUTTON_THUMBL:
handleCommitKeyboardKey(Keyboard.KEYCODE_MODE_CHANGE, null);
break;
case KeyEvent.KEYCODE_BUTTON_THUMBR:
handleCommitKeyboardKey(LeanbackKeyboardView.KEYCODE_CAPS_LOCK, null);
break;
case KeyEvent.KEYCODE_ENTER:
if (mContainer != null) {
KeyFocus keyFocus = mContainer.getCurrFocus();
if (keyFocus != null && keyFocus.type == KeyFocus.TYPE_SUGGESTION) {
mInputListener.onEntry(InputListener.ENTRY_TYPE_SUGGESTION, 0,
mContainer.getSuggestionText(keyFocus.index));
}
}
mInputListener.onEntry(InputListener.ENTRY_TYPE_DISMISS, 0, null);
break;
default:
handled = false;
break;
}
return handled;
}
public void updateSuggestions(ArrayList<String> suggestions) {
if (mContainer != null) {
mContainer.updateSuggestions(suggestions);
}
}
@Override
public void onVoiceResult(String result) {
mInputListener.onEntry(InputListener.ENTRY_TYPE_VOICE, 0, result);
}
@Override
public void onDismiss(boolean fromVoice) {
if (fromVoice) {
mInputListener.onEntry(InputListener.ENTRY_TYPE_VOICE_DISMISS, 0, null);
} else {
mInputListener.onEntry(InputListener.ENTRY_TYPE_DISMISS, 0, null);
}
}
private boolean isEnterKey(int keyCode) {
return getSimplifiedKey(keyCode) == KeyEvent.KEYCODE_DPAD_CENTER;
}
private int getSimplifiedKey(int keyCode) {
// simplify for dpad center
keyCode = (keyCode == KeyEvent.KEYCODE_DPAD_CENTER ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER ||
keyCode == KeyEvent.KEYCODE_BUTTON_A) ? KeyEvent.KEYCODE_DPAD_CENTER : keyCode;
// simply for back
keyCode = (keyCode == KeyEvent.KEYCODE_BUTTON_B ? KeyEvent.KEYCODE_BACK : keyCode);
return keyCode;
}
}