/* * Copyright (C) 2013 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.talkback; import android.content.Context; import android.annotation.TargetApi; import android.content.SharedPreferences; import android.os.Build; import android.view.KeyEvent; import com.android.talkback.keyboard.DefaultKeyComboModel; import com.android.talkback.keyboard.KeyComboModel; import com.android.talkback.keyboard.KeyComboModelApp; import com.android.utils.SharedPreferencesUtils; import com.google.android.marvin.talkback.TalkBackService; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; /** * Manages state related to detecting key combinations. * * TODO: move KeyComboManager under package talkback.keyboard. */ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) public class KeyComboManager implements TalkBackService.KeyEventListener, TalkBackService.ServiceStateListener { public static final int MIN_API_LEVEL = Build.VERSION_CODES.JELLY_BEAN_MR2; public static final int NO_MATCH = -1; public static final int PARTIAL_MATCH = 1; public static final int EXACT_MATCH = 2; public static final int ACTION_UNKNOWN = -1; public static final int ACTION_NAVIGATE_NEXT = 1; public static final int ACTION_NAVIGATE_PREVIOUS = 2; public static final int ACTION_NAVIGATE_FIRST = 3; public static final int ACTION_NAVIGATE_LAST = 4; public static final int ACTION_PERFORM_CLICK = 5; public static final int ACTION_BACK = 6; public static final int ACTION_HOME = 7; public static final int ACTION_RECENTS = 8; public static final int ACTION_NOTIFICATION = 9; public static final int ACTION_SUSPEND_OR_RESUME = 10; public static final int ACTION_GRANULARITY_INCREASE = 11; public static final int ACTION_GRANULARITY_DECREASE = 12; public static final int ACTION_READ_FROM_TOP = 13; public static final int ACTION_READ_FROM_NEXT_ITEM = 14; public static final int ACTION_TOGGLE_SEARCH = 15; public static final int ACTION_LOCAL_CONTEXT_MENU = 16; public static final int ACTION_GLOBAL_CONTEXT_MENU = 17; public static final int ACTION_NAVIGATE_UP = 18; public static final int ACTION_NAVIGATE_DOWN = 19; public static final int ACTION_NAVIGATE_NEXT_WORD = 20; public static final int ACTION_NAVIGATE_PREVIOUS_WORD = 21; public static final int ACTION_NAVIGATE_NEXT_CHARACTER = 22; public static final int ACTION_NAVIGATE_PREVIOUS_CHARACTER = 23; public static final int ACTION_PERFORM_LONG_CLICK = 24; public static final int ACTION_NAVIGATE_NEXT_HEADING = 25; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING = 26; public static final int ACTION_NAVIGATE_NEXT_BUTTON = 27; public static final int ACTION_NAVIGATE_PREVIOUS_BUTTON = 28; public static final int ACTION_NAVIGATE_NEXT_CHECKBOX = 29; public static final int ACTION_NAVIGATE_PREVIOUS_CHECKBOX = 30; public static final int ACTION_NAVIGATE_NEXT_ARIA_LANDMARK = 31; public static final int ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK = 32; public static final int ACTION_NAVIGATE_NEXT_EDIT_FIELD = 33; public static final int ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD = 34; public static final int ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM = 35; public static final int ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM = 36; public static final int ACTION_NAVIGATE_NEXT_HEADING_1 = 37; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_1 = 38; public static final int ACTION_NAVIGATE_NEXT_HEADING_2 = 39; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_2 = 40; public static final int ACTION_NAVIGATE_NEXT_HEADING_3 = 41; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_3 = 42; public static final int ACTION_NAVIGATE_NEXT_HEADING_4 = 43; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_4 = 44; public static final int ACTION_NAVIGATE_NEXT_HEADING_5 = 45; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_5 = 46; public static final int ACTION_NAVIGATE_NEXT_HEADING_6 = 47; public static final int ACTION_NAVIGATE_PREVIOUS_HEADING_6 = 48; public static final int ACTION_NAVIGATE_NEXT_LINK = 49; public static final int ACTION_NAVIGATE_PREVIOUS_LINK = 50; public static final int ACTION_NAVIGATE_NEXT_CONTROL = 51; public static final int ACTION_NAVIGATE_PREVIOUS_CONTROL = 52; public static final int ACTION_NAVIGATE_NEXT_GRAPHIC = 53; public static final int ACTION_NAVIGATE_PREVIOUS_GRAPHIC = 54; public static final int ACTION_NAVIGATE_NEXT_LIST_ITEM = 55; public static final int ACTION_NAVIGATE_PREVIOUS_LIST_ITEM = 56; public static final int ACTION_NAVIGATE_NEXT_LIST = 57; public static final int ACTION_NAVIGATE_PREVIOUS_LIST = 58; public static final int ACTION_NAVIGATE_NEXT_TABLE = 59; public static final int ACTION_NAVIGATE_PREVIOUS_TABLE = 60; public static final int ACTION_NAVIGATE_NEXT_COMBOBOX = 61; public static final int ACTION_NAVIGATE_PREVIOUS_COMBOBOX = 62; public static final int ACTION_NAVIGATE_NEXT_WINDOW = 63; public static final int ACTION_NAVIGATE_PREVIOUS_WINDOW = 64; public static final int ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS = 65; public static final int ACTION_OPEN_TALKBACK_SETTINGS = 66; private static final int KEY_EVENT_MODIFIER_MASK = KeyEvent.META_SHIFT_ON | KeyEvent.META_CTRL_ON | KeyEvent.META_ALT_ON | KeyEvent.META_META_ON; static final String CONCATINATION_STR = " + "; private static final String KEYCODE_PREFIX = "KEYCODE_"; /** * When user has pressed same key twice less than this interval, we handle them as double tap. */ private static final long TIME_TO_DETECT_DOUBLE_TAP = 1000; // ms private static final boolean IS_IN_ARC = TalkBackService.isInArc(); private static final int DEFAULT_KEYMAP = IS_IN_ARC ? R.string.default_keymap_entry_value : R.string.classic_keymap_entry_value; /** * Returns kecComboCode that represent keyEvent. */ public static long getKeyComboCode(KeyEvent keyEvent) { if (keyEvent == null) { return KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; } int modifier = keyEvent.getModifiers() & KEY_EVENT_MODIFIER_MASK; return getKeyComboCode(modifier, getConvertedKeyCode(keyEvent)); } /** * Returns key combo code which is combination of modifier and keycode. * @param modifier * @param keycode * @return */ public static long getKeyComboCode(int modifier, int keycode) { return (((long) modifier) << 32) + keycode; } /** * Returns modifier part of key combo code. * @param keyComboCode * @return */ public static int getModifier(long keyComboCode) { return (int) (keyComboCode >> 32); } /** * Returns key code part of key combo code. * @param keyComboCode * @return */ public static int getKeyCode(long keyComboCode) { return (int) (keyComboCode); } /** * Returns converted key code. This method converts the following key events. * - Convert KEYCODE_HOME with meta to KEYCODE_ENTER. * - Convert KEYCODE_BACK with meta to KEYCODE_DEL. * @param event Key event to be converted. * @return Converted key code. */ static int getConvertedKeyCode(KeyEvent event) { // We care only when meta key is pressed with. if ((event.getModifiers() & KeyEvent.META_META_ON) == 0) { return event.getKeyCode(); } if (event.getKeyCode() == KeyEvent.KEYCODE_HOME) { return KeyEvent.KEYCODE_ENTER; } else if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { return KeyEvent.KEYCODE_DEL; } else { return event.getKeyCode(); } } /** Whether the user performed a combo during the current interaction. */ private boolean mPerformedCombo; /** Whether the user may be performing a combo and we should intercept keys. */ private boolean mHasPartialMatch; private Set<Integer> mCurrentKeysDown = new HashSet<>(); private Set<Integer> mPassedKeys = new HashSet<>(); private long mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; private long mCurrentKeyComboTime = 0; private long mPreviousKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; private long mPreviousKeyComboTime = 0; /** The listener that receives callbacks when a combo is recognized. */ private final List<KeyComboListener> mListeners = new LinkedList<>(); private Context mContext; private boolean mMatchKeyCombo = true; private KeyComboModel mKeyComboModel; private TalkBackServiceStateHelper mServiceStateHelper; private KeyboardShortcutDialogPreference mKeyboardShortcutDialogPreference; public static KeyComboManager create(Context context) { if (Build.VERSION.SDK_INT >= MIN_API_LEVEL) { return new KeyComboManager(context); } return null; } private KeyComboManager(Context context) { mContext = context; mKeyComboModel = createKeyComboModelFor(getKeymap()); mServiceStateHelper = new TalkBackServiceStateHelperApp(); initializeDefaultPreferenceValues(); } /** * Store default values in preferences to show them in preferences UI. */ private void initializeDefaultPreferenceValues() { SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext); if (preferences.contains(mContext.getString(R.string.pref_select_keymap_key))) { return; } preferences.edit().putString(mContext.getString(R.string.pref_select_keymap_key), mContext.getString(DEFAULT_KEYMAP)).apply(); } /** * Sets KeyboardShortcutDialogPreference for key events. If it's set, it can listen and consume * key events before KeyComboManager does. Sets null to remove current one. */ public void setKeyboardShortcutDialogPreferenceForKeyEvents( KeyboardShortcutDialogPreference keyboardShortcutDialogPreference) { mKeyboardShortcutDialogPreference = keyboardShortcutDialogPreference; } /** * Returns keymap by reading preference. */ public String getKeymap() { SharedPreferences preferences = SharedPreferencesUtils.getSharedPreferences(mContext); return preferences.getString(mContext.getString(R.string.pref_select_keymap_key), mContext.getString(DEFAULT_KEYMAP)); } /** * Creates key combo model for specified keymap. * @param keymap Keymap. * @return Key combo model. null will be returned if keymap is invalid. */ public KeyComboModel createKeyComboModelFor(String keymap) { if (keymap.equals(mContext.getString(R.string.classic_keymap_entry_value))) { return new KeyComboModelApp(mContext); } else if (keymap.equals(mContext.getString(R.string.default_keymap_entry_value))) { return new DefaultKeyComboModel(mContext); } return null; } /** * Sets TalkBackServiceStateHelper for testing. */ void setTalkBackServiceStateHelperForTesting( TalkBackServiceStateHelper serviceStateHelper) { mServiceStateHelper = serviceStateHelper; } /** * Returns key combo model. * @return */ public KeyComboModel getKeyComboModel() { return mKeyComboModel; } /** * Sets key combo model. * TODO: replace this method with setKeymap. */ public void setKeyComboModel(KeyComboModel keyComboModel) { mKeyComboModel = keyComboModel; } /** * Returns corresponding action id to key. If invalid value is passed as key, ACTION_UNKNOWN * will be returned. * @param key * @return */ private int getActionIdFromKey(String key) { if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next))) { return ACTION_NAVIGATE_NEXT; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous))) { return ACTION_NAVIGATE_PREVIOUS; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_first))) { return ACTION_NAVIGATE_FIRST; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_last))) { return ACTION_NAVIGATE_LAST; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_click))) { return ACTION_PERFORM_CLICK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_back))) { return ACTION_BACK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_home))) { return ACTION_HOME; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_recents))) { return ACTION_RECENTS; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_notifications))) { return ACTION_NOTIFICATION; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend))) { return ACTION_SUSPEND_OR_RESUME; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_increase))) { return ACTION_GRANULARITY_INCREASE; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_granularity_decrease))) { return ACTION_GRANULARITY_DECREASE; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_top))) { return ACTION_READ_FROM_TOP; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_read_from_next_item))) { return ACTION_READ_FROM_NEXT_ITEM; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_toggle_search))) { return ACTION_TOGGLE_SEARCH; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_local_context_menu))) { return ACTION_LOCAL_CONTEXT_MENU; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_other_global_context_menu))) { return ACTION_GLOBAL_CONTEXT_MENU; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_up))) { return ACTION_NAVIGATE_UP; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_down))) { return ACTION_NAVIGATE_DOWN; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_word))) { return ACTION_NAVIGATE_NEXT_WORD; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_word))) { return ACTION_NAVIGATE_PREVIOUS_WORD; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_character))) { return ACTION_NAVIGATE_NEXT_CHARACTER; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_character))) { return ACTION_NAVIGATE_PREVIOUS_CHARACTER; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_perform_long_click))) { return ACTION_PERFORM_LONG_CLICK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading))) { return ACTION_NAVIGATE_NEXT_HEADING; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_heading))) { return ACTION_NAVIGATE_PREVIOUS_HEADING; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_button))) { return ACTION_NAVIGATE_NEXT_BUTTON; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_button))) { return ACTION_NAVIGATE_PREVIOUS_BUTTON; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_checkbox))) { return ACTION_NAVIGATE_NEXT_CHECKBOX; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_checkbox))) { return ACTION_NAVIGATE_PREVIOUS_CHECKBOX; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_next_aria_landmark))) { return ACTION_NAVIGATE_NEXT_ARIA_LANDMARK; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_aria_landmark))) { return ACTION_NAVIGATE_PREVIOUS_ARIA_LANDMARK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_edit_field))) { return ACTION_NAVIGATE_NEXT_EDIT_FIELD; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_edit_field))) { return ACTION_NAVIGATE_PREVIOUS_EDIT_FIELD; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_next_focusable_item))) { return ACTION_NAVIGATE_NEXT_FOCUSABLE_ITEM; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_focusable_item))) { return ACTION_NAVIGATE_PREVIOUS_FOCUSABLE_ITEM; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_1))) { return ACTION_NAVIGATE_NEXT_HEADING_1; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_1))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_1; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_2))) { return ACTION_NAVIGATE_NEXT_HEADING_2; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_2))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_2; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_3))) { return ACTION_NAVIGATE_NEXT_HEADING_3; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_3))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_3; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_4))) { return ACTION_NAVIGATE_NEXT_HEADING_4; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_4))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_4; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_5))) { return ACTION_NAVIGATE_NEXT_HEADING_5; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_5))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_5; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_heading_6))) { return ACTION_NAVIGATE_NEXT_HEADING_6; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_heading_6))) { return ACTION_NAVIGATE_PREVIOUS_HEADING_6; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_link))) { return ACTION_NAVIGATE_NEXT_LINK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_link))) { return ACTION_NAVIGATE_PREVIOUS_LINK; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_control))) { return ACTION_NAVIGATE_NEXT_CONTROL; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_control))) { return ACTION_NAVIGATE_PREVIOUS_CONTROL; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_graphic))) { return ACTION_NAVIGATE_NEXT_GRAPHIC; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_graphic))) { return ACTION_NAVIGATE_PREVIOUS_GRAPHIC; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list_item))) { return ACTION_NAVIGATE_NEXT_LIST_ITEM; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_navigate_previous_list_item))) { return ACTION_NAVIGATE_PREVIOUS_LIST_ITEM; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_list))) { return ACTION_NAVIGATE_NEXT_LIST; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_list))) { return ACTION_NAVIGATE_PREVIOUS_LIST; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_table))) { return ACTION_NAVIGATE_NEXT_TABLE; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_table))) { return ACTION_NAVIGATE_PREVIOUS_TABLE; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_combobox))) { return ACTION_NAVIGATE_NEXT_COMBOBOX; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_combobox))) { return ACTION_NAVIGATE_PREVIOUS_COMBOBOX; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_next_window))) { return ACTION_NAVIGATE_NEXT_WINDOW; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_navigate_previous_window))) { return ACTION_NAVIGATE_PREVIOUS_WINDOW; } if (key.equals(mContext.getString( R.string.keycombo_shortcut_open_manage_keyboard_shortcuts))) { return ACTION_OPEN_MANAGE_KEYBOARD_SHORTCUTS; } if (key.equals(mContext.getString(R.string.keycombo_shortcut_open_talkback_settings))) { return ACTION_OPEN_TALKBACK_SETTINGS; } return ACTION_UNKNOWN; } /** * Returns true if key combination of the key should be always processed. * @param key * @return */ private boolean alwaysProcessCombo(String key) { return key.equals(mContext.getString(R.string.keycombo_shortcut_global_suspend)); } /** * Sets the listener that receives callbacks when the user performs key * combinations. * * @param listener The listener that receives callbacks. */ public void addListener(KeyComboListener listener) { mListeners.add(listener); } /** * Set whether to process keycombo */ public void setMatchKeyCombo(boolean value) { mMatchKeyCombo = value; } /** * Returns user friendly string representations of key combo code */ public String getKeyComboStringRepresentation(long keyComboCode) { if (keyComboCode == KeyComboModel.KEY_COMBO_CODE_UNASSIGNED) { return mContext.getString(R.string.keycombo_unassigned); } int triggerModifier = mKeyComboModel.getTriggerModifier(); int modifier = getModifier(keyComboCode); int modifierWithoutTriggerModifier = modifier & ~triggerModifier; int keyCode = getKeyCode(keyComboCode); StringBuilder sb = new StringBuilder(); // Append trigger modifier if key combo code contains it. if ((triggerModifier & modifier) != 0) { appendModifiers(triggerModifier, sb); } // Append modifier except trigger modifier. appendModifiers(modifierWithoutTriggerModifier, sb); // Append key code. if (keyCode > 0 && !KeyEvent.isModifierKey(keyCode)) { appendPlusSignIfNotEmpty(sb); switch (keyCode) { case KeyEvent.KEYCODE_DPAD_RIGHT: sb.append(mContext.getString(R.string.keycombo_key_arrow_right)); break; case KeyEvent.KEYCODE_DPAD_LEFT: sb.append(mContext.getString(R.string.keycombo_key_arrow_left)); break; case KeyEvent.KEYCODE_DPAD_UP: sb.append(mContext.getString(R.string.keycombo_key_arrow_up)); break; case KeyEvent.KEYCODE_DPAD_DOWN: sb.append(mContext.getString(R.string.keycombo_key_arrow_down)); break; default: String keyCodeString = KeyEvent.keyCodeToString(keyCode); if (keyCodeString != null) { String keyCodeNoPrefix; if (keyCodeString.startsWith(KEYCODE_PREFIX)) { keyCodeNoPrefix = keyCodeString.substring(KEYCODE_PREFIX.length()); } else { keyCodeNoPrefix = keyCodeString; } sb.append(keyCodeNoPrefix.replace('_', ' ')); } break; } } return sb.toString(); } /** * Appends modifier. */ private void appendModifiers(int modifier, StringBuilder sb) { appendModifier(modifier, KeyEvent.META_ALT_ON, mContext.getString(R.string.keycombo_key_modifier_alt), sb); appendModifier(modifier, KeyEvent.META_SHIFT_ON, mContext.getString(R.string.keycombo_key_modifier_shift), sb); appendModifier(modifier, KeyEvent.META_CTRL_ON, mContext.getString(R.string.keycombo_key_modifier_ctrl), sb); appendModifier(modifier, KeyEvent.META_META_ON, mContext.getString(R.string.keycombo_key_modifier_meta), sb); } /** * Appends string representation of target modifier if modifier contains it. */ private void appendModifier(int modifier, int targetModifier, String stringRepresentation, StringBuilder sb) { if ((modifier & targetModifier) != 0) { appendPlusSignIfNotEmpty(sb); sb.append(stringRepresentation); } } private void appendPlusSignIfNotEmpty(StringBuilder sb) { if (sb.length() > 0) { sb.append(CONCATINATION_STR); } } /** * Handles incoming key events. May intercept keys if the user seems to be * performing a key combo. * * @param event The key event. * @return {@code true} if the key was intercepted. */ @Override public boolean onKeyEvent(KeyEvent event) { if (mKeyboardShortcutDialogPreference != null) { if (mKeyboardShortcutDialogPreference.onKeyEventFromKeyComboManager(event)) { return true; } } if (!mHasPartialMatch && !mPerformedCombo && (!mMatchKeyCombo || mListeners.isEmpty())) { return false; } switch (event.getAction()) { case KeyEvent.ACTION_DOWN: return onKeyDown(event); case KeyEvent.ACTION_MULTIPLE: return mHasPartialMatch; case KeyEvent.ACTION_UP: return onKeyUp(event); default: return false; } } @Override public boolean processWhenServiceSuspended() { return true; } private KeyEvent convertKeyEventInArc(KeyEvent event) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_HOME: case KeyEvent.KEYCODE_BACK: // In Arc, Search + X is sent as KEYCODE_X with META_META_ON in Android. Android // converts META_META_ON + KEYCODE_ENTER and META_META_ON + KEYCODE_DEL to // KEYCODE_HOME and KEYCODE_BACK without META_META_ON. We add META_META_ON to this // key event to satisfy trigger modifier condition. We don't need to do this in // non-Arc since Search + X is usually sent as KEYCODE_X with META_META_ON and // META_META_LEFT_ON or META_META_RIGHT_ON. return new KeyEvent(event.getDownTime(), event.getEventTime(), event.getAction(), event.getKeyCode(), event.getRepeatCount(), event.getMetaState() | KeyEvent.META_META_ON); default: return event; } } private boolean onKeyDown(KeyEvent event) { if (IS_IN_ARC) { event = convertKeyEventInArc(event); } mCurrentKeysDown.add(event.getKeyCode()); mCurrentKeyComboCode = getKeyComboCode(event); mCurrentKeyComboTime = event.getDownTime(); // Check modifier. int triggerModifier = mKeyComboModel.getTriggerModifier(); boolean hasModifier = triggerModifier != KeyComboModel.NO_MODIFIER; if (hasModifier && (triggerModifier & event.getModifiers()) != triggerModifier) { // Do nothing if condition of modifier is not met. mPassedKeys.addAll(mCurrentKeysDown); return false; } boolean isServiceActive = mServiceStateHelper.isServiceActive(); // If the current set of keys is a partial combo, consume the event. mHasPartialMatch = false; for (Map.Entry<String, Long> entry : mKeyComboModel.getKeyComboCodeMap().entrySet()) { if (!isServiceActive && !alwaysProcessCombo(entry.getKey())) { continue; } final int match = matchKeyEventWith(event, triggerModifier, entry.getValue()); if (match == EXACT_MATCH) { for (KeyComboListener listener : mListeners) { if (listener.onComboPerformed(getActionIdFromKey(entry.getKey()))) { mPerformedCombo = true; return true; } } } if (match == PARTIAL_MATCH) { mHasPartialMatch = true; } } // Do not handle key event if user has pressed search key (meta key) twice to open search // app. if (hasModifier && triggerModifier == KeyEvent.META_META_ON) { if (mPreviousKeyComboCode == mCurrentKeyComboCode && mCurrentKeyComboTime - mPreviousKeyComboTime < TIME_TO_DETECT_DOUBLE_TAP && (mCurrentKeyComboCode == KeyComboManager.getKeyComboCode( KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_RIGHT) || mCurrentKeyComboCode == KeyComboManager.getKeyComboCode( KeyEvent.META_META_ON, KeyEvent.KEYCODE_META_LEFT))) { // Set KEY_COMBO_CODE_UNASSIGNED not to open search app again with following search // key event. mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; mPassedKeys.addAll(mCurrentKeysDown); return false; } } if (!mHasPartialMatch) { mPassedKeys.addAll(mCurrentKeysDown); } return mHasPartialMatch; } private int matchKeyEventWith(KeyEvent event, int triggerModifier, long keyComboCode) { int keyCode = getConvertedKeyCode(event); int metaState = event.getModifiers() & KEY_EVENT_MODIFIER_MASK; int targetKeyCode = getKeyCode(keyComboCode); int targetMetaState = getModifier(keyComboCode) | triggerModifier; // Handle exact matches first. if (metaState == targetMetaState && keyCode == targetKeyCode) { return EXACT_MATCH; } if (targetMetaState != 0 && metaState == 0) { return NO_MATCH; } // Otherwise, all modifiers must be down. if (KeyEvent.isModifierKey(keyCode) && targetMetaState != 0 && (targetMetaState & metaState) != 0) { // Partial match. return PARTIAL_MATCH; } // No match. return NO_MATCH; } private boolean onKeyUp(KeyEvent event) { if (IS_IN_ARC) { event = convertKeyEventInArc(event); } mCurrentKeysDown.remove(event.getKeyCode()); boolean passed = mPassedKeys.remove(event.getKeyCode()); if (mCurrentKeysDown.isEmpty()) { // The interaction is over, reset the state. mPerformedCombo = false; mHasPartialMatch = false; mPreviousKeyComboCode = mCurrentKeyComboCode; mPreviousKeyComboTime = mCurrentKeyComboTime; mCurrentKeyComboCode = KeyComboModel.KEY_COMBO_CODE_UNASSIGNED; mCurrentKeyComboTime = 0; mPassedKeys.clear(); } return !passed; } @Override public void onServiceStateChanged(int newState) { // Unfortunately, key events are lost when the TalkBackService becomes active. If a key-down // occurs that triggers TalkBack to resume, the corresponding key-up event will not be // sent, causing the partially-matched key history to become inconsistent. // The following method will cause the key history to be reset. setMatchKeyCombo(mMatchKeyCombo); } public interface KeyComboListener { public boolean onComboPerformed(int id); } public interface TalkBackServiceStateHelper { public boolean isServiceActive(); } private class TalkBackServiceStateHelperApp implements TalkBackServiceStateHelper { @Override public boolean isServiceActive() { return TalkBackService.isServiceActive(); } } }