/*
* Copyright (C) 2015 Google Inc.
*
* 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.switchaccess;
import android.content.pm.PackageManager;
import android.support.annotation.NonNull;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import com.android.talkback.R;
import com.android.utils.SharedPreferencesUtils;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.DialogPreference;
import android.preference.PreferenceManager;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Dialog to retrieve a key combination from the user for use in a preference.
* This class assumes that all preferences are in the default shared preferences. It uses this
* assumption to verify that key combinations can be assigned to at most one preference.
*/
public class KeyComboPreference extends DialogPreference implements DialogInterface.OnKeyListener {
/**
* A value guaranteed not to match any extended key code
*/
public static final long INVALID_EXTENDED_KEY_CODE = -1;
/* Adapter to display list of keys assigned to this preference */
private final ArrayAdapter<CharSequence> mKeyListAdapter;
/**
* A set of longs which contain both the keys pressed along with information about modifiers
*/
private Set<Long> mKeyCombos;
/**
* Convert a KeyEvent into a long which can be kept in settings and compared
* to key presses when the service is in use.
*
* @param keyEvent The key event to convert. The (non-extended) keycode
* must not be a modifier.
* @return An extended key code that includes modifier information
*/
public static long keyEventToExtendedKeyCode(KeyEvent keyEvent) {
long returnValue = keyEvent.getKeyCode();
returnValue |= (keyEvent.isShiftPressed()) ? (((long) KeyEvent.META_SHIFT_ON) << 32) : 0;
returnValue |= (keyEvent.isCtrlPressed()) ? (((long) KeyEvent.META_CTRL_ON) << 32) : 0;
returnValue |= (keyEvent.isAltPressed()) ? (((long) KeyEvent.META_ALT_ON) << 32) : 0;
return returnValue;
}
/**
* Returns the set of long codes of the keys assigned to a preference.
*
* @param context The context to use for a PreferenceManager
* @param resId The resource Id of the preference key
* @return The {@code Set<Long>} of the keys assigned to the preference
*/
public static Set<Long> getKeyCodesForPreference(Context context, int resId) {
return getKeyCodesForPreference(context, context.getString(resId));
}
/**
* Returns the set of long codes of the keys assigned to a preference.
*
* @param context The context to use for a PreferenceManager
* @param key The preference key
* @return The {@code Set<Long>} of the keys assigned to the preference
*/
public static Set<Long> getKeyCodesForPreference(Context context, String key) {
return getKeyCodesForPreference(SharedPreferencesUtils.getSharedPreferences(context),
key);
}
/**
* Returns the set of long codes of the keys assigned to a preference.
*
* @param prefs The shared preferences
* @param key The preference key
* @return The {@code Set<Long>} of the keys assigned to the preference
*/
public static Set<Long> getKeyCodesForPreference(SharedPreferences prefs, String key) {
Set<Long> result = new HashSet<>();
try {
Set<String> longPrefStringSet = prefs.getStringSet(key, Collections.EMPTY_SET);
for (String longPrefString : longPrefStringSet) {
result.add(Long.valueOf(longPrefString));
}
} catch (ClassCastException e) {
/*
* Key maps to preference that is not a set. Fall back on legacy behavior before we
* supported multiple keys
*/
long keyCode = prefs.getLong(key, KeyComboPreference.INVALID_EXTENDED_KEY_CODE);
if (keyCode != INVALID_EXTENDED_KEY_CODE ) {
result.add(keyCode);
}
} catch (NumberFormatException e) {
/*
* One of the strings in the string set can't be converted to a Long. This should
* not be possible unless the preferences are corrupted. Remove the preference and
* return an empty set.
*/
prefs.edit().remove(key).apply();
}
return result;
}
/**
* @param context Current context
* @param attrs Attribute set passed to DialogInterface
*/
public KeyComboPreference(Context context, AttributeSet attrs) {
super(context, attrs);
mKeyListAdapter = new ArrayAdapter<CharSequence>(
getContext(), android.R.layout.simple_list_item_1, new ArrayList<CharSequence>());
setDialogLayoutResource(R.layout.switch_access_key_combo_preference_layout);
}
@Override
public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) {
/* If we're ignoring this key, don't handle it */
if (isKeyCodeToIgnore(keyCode)) {
return false;
}
/* If this is a modifier key, ignore it */
if (KeyEvent.isModifierKey(keyCode)) {
return true;
}
if (event.getAction() == KeyEvent.ACTION_DOWN) {
Long keyCombo = keyEventToExtendedKeyCode(event);
if (mKeyCombos.contains(keyCombo)) {
/* Don't check other keys - if it's a duplicate, it's being removed */
mKeyCombos.remove(keyCombo);
updateKeyListAdapter();
} else {
CharSequence titleOfOtherPrefForKey = getTitleOfOtherActionAssociatedWith(keyCombo);
if (titleOfOtherPrefForKey != null) {
CharSequence toastText = String.format(
getContext().getString(R.string.toast_msg_key_already_assigned),
describeExtendedKeyCode(keyCombo),
titleOfOtherPrefForKey);
Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT).show();
} else {
mKeyCombos.add(keyCombo);
updateKeyListAdapter();
}
}
}
return true;
}
@Override
public CharSequence getSummary() {
if (mKeyCombos == null) {
mKeyCombos = getKeyCodesForPreference(getContext(), getKey());
}
int numKeysAssigned = mKeyCombos.size();
if (numKeysAssigned == 1) {
return describeExtendedKeyCode(mKeyCombos.iterator().next());
} else {
return getContext().getResources().getQuantityString(
R.plurals.label_num_keys_assigned_format, numKeysAssigned, numKeysAssigned);
}
}
@Override
protected void onDialogClosed(boolean positiveResult) {
super.onDialogClosed(positiveResult);
if (positiveResult) {
Set<String> longPrefStringSet = new HashSet<>(mKeyCombos.size());
for (Long keyCombo : mKeyCombos) {
longPrefStringSet.add(keyCombo.toString());
}
SharedPreferences sharedPreferences = getSharedPreferences();
SharedPreferences.Editor editor = sharedPreferences.edit();
String key = getKey();
editor.putStringSet(key, longPrefStringSet);
editor.apply();
callChangeListener(longPrefStringSet);
notifyChanged();
} else {
mKeyCombos = getKeyCodesForPreference(getContext(), getKey());
}
}
@Override
protected void onBindView(View view) {
super.onBindView(view);
/* Some translations of Key Combination overflow a single line. Allow wrapping. */
TextView textView = (TextView) view.findViewById(android.R.id.title);
if (textView != null) {
textView.setSingleLine(false);
}
}
@Override
protected void onBindDialogView(@NonNull View view) {
super.onBindDialogView(view);
Button resetButton = (Button) view.findViewById(R.id.key_combo_preference_reset_button);
if (getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) {
resetButton.setFocusable(false);
}
resetButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mKeyCombos.clear();
updateKeyListAdapter();
}
});
ListView listView = (ListView) view.findViewById(R.id.key_combo_preference_key_list);
mKeyCombos = getKeyCodesForPreference(getContext(), getKey());
updateKeyListAdapter();
listView.setAdapter(mKeyListAdapter);
}
@Override
protected void showDialog(Bundle state) {
super.showDialog(state);
AlertDialog alertDialog = (AlertDialog) getDialog();
if (alertDialog == null) {
return;
}
if (getContext().getPackageManager().hasSystemFeature("android.hardware.touchscreen")) {
/* Disable focus for buttons to prevent them being highlighted when keys are pressed */
alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setFocusable(false);
alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE).setFocusable(false);
}
alertDialog.setOnKeyListener(this);
setPositiveButtonText(R.string.save);
setNegativeButtonText(android.R.string.cancel);
setDialogIcon(null);
}
@Override
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
super.onPrepareDialogBuilder(builder);
/* Take the title from the preference, not the xml file */
builder.setTitle(getTitle());
}
/**
* Create a string that describes the extended key code. This string can be
* shown to the user to indicate the current choice of key.
*
* @param extendedKeyCode The key code to describe
* @return A description of the key code
*/
private String describeExtendedKeyCode(long extendedKeyCode) {
if (extendedKeyCode == INVALID_EXTENDED_KEY_CODE) {
return getContext().getString(R.string.no_key_assigned);
}
/* If meta keys are pressed, build a string to represent this combination of keys */
StringBuilder keystrokeDescriptionBuilder = new StringBuilder();
if ((extendedKeyCode & (((long) KeyEvent.META_CTRL_ON) << 32)) != 0) {
keystrokeDescriptionBuilder.append(
getContext().getString(R.string.key_combo_preference_control_plus));
}
if ((extendedKeyCode & (((long) KeyEvent.META_ALT_ON) << 32)) != 0) {
keystrokeDescriptionBuilder.append(
getContext().getString(R.string.key_combo_preference_alt_plus));
}
if ((extendedKeyCode & (((long) KeyEvent.META_SHIFT_ON) << 32)) != 0) {
keystrokeDescriptionBuilder.append(
getContext().getString(R.string.key_combo_preference_shift_plus));
}
/* Try to obtain a localized representation of the key */
KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, (int) extendedKeyCode);
char displayLabel = keyEvent.getDisplayLabel();
if (displayLabel != 0 && !Character.isWhitespace(displayLabel)) {
keystrokeDescriptionBuilder.append(displayLabel);
} else if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_SPACE) {
keystrokeDescriptionBuilder.append(getContext().getString(R.string.name_of_space_bar));
} else if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
keystrokeDescriptionBuilder.append(getContext().getString(R.string.name_of_enter_key));
} else if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_TAB) {
keystrokeDescriptionBuilder.append(getContext().getString(R.string.name_of_tab_key));
} else {
/* Fall back on non-localized descriptions */
keystrokeDescriptionBuilder.append(KeyEvent.keyCodeToString((int) extendedKeyCode));
}
return keystrokeDescriptionBuilder.toString();
}
private boolean isKeyCodeToIgnore(int keyCode) {
return ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER)
|| (keyCode == KeyEvent.KEYCODE_DPAD_DOWN)
|| (keyCode == KeyEvent.KEYCODE_DPAD_UP)
|| (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)
|| (keyCode == KeyEvent.KEYCODE_BACK)
|| (keyCode == KeyEvent.KEYCODE_DPAD_LEFT));
}
private void updateKeyListAdapter() {
mKeyListAdapter.clear();
for (long keyCombo : mKeyCombos) {
mKeyListAdapter.add(describeExtendedKeyCode(keyCombo));
}
/* Sort the list so the keys appear in a consistent place */
mKeyListAdapter.sort(new Comparator<CharSequence>() {
@Override
public int compare(CharSequence charSequence0, CharSequence charSequence1) {
return String.CASE_INSENSITIVE_ORDER
.compare(charSequence0.toString(), charSequence1.toString());
}
});
}
private CharSequence getTitleOfOtherActionAssociatedWith(Long extendedKeyCode) {
/*
* Collect all KeyComboPreferences. It's somewhat inefficient to iterate through all
* preferences every time, but it's only done during configuration when the user presses a
* key. Lazily-initializing a static list would assume that there's no way a preference
* will be added after the initialization. That assumption was not true during testing,
* which may have been specific to the testing environment but may also indicate that
* problematic situations can arise.
*/
PreferenceManager preferenceManager = getPreferenceManager();
SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(getContext());
Map<String, ?> prefMap = prefs.getAll();
String myKey = getKey();
for (String key : prefMap.keySet()) {
if (!myKey.equals(key)) {
Object preferenceObject = preferenceManager.findPreference(key);
if (preferenceObject instanceof KeyComboPreference) {
KeyComboPreference otherPref = (KeyComboPreference) preferenceObject;
if (getKeyCodesForPreference(getContext(), key).contains(extendedKeyCode)) {
return preferenceManager.findPreference(key).getTitle();
}
}
}
}
return null;
}
}