/* * 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 mKeyChangeHistory = new ArrayList(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 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; } }