blob: d9f2dcf2e8967086a836bdc83fbf152b9604e799 [file] [log] [blame]
/*
* Copyright (C) 2006 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 android.text.method;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.icu.lang.UCharacter;
import android.icu.lang.UProperty;
import android.icu.text.DecimalFormatSymbols;
import android.text.InputType;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.view.KeyEvent;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.ArrayUtils;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Locale;
/**
* For digits-only text entry
* <p></p>
* As for all implementations of {@link KeyListener}, this class is only concerned
* with hardware keyboards. Software input methods have no obligation to trigger
* the methods in this class.
*/
public class DigitsKeyListener extends NumberKeyListener
{
private char[] mAccepted;
private boolean mNeedsAdvancedInput;
private final boolean mSign;
private final boolean mDecimal;
private final boolean mStringMode;
@Nullable
private final Locale mLocale;
private static final String DEFAULT_DECIMAL_POINT_CHARS = ".";
private static final String DEFAULT_SIGN_CHARS = "-+";
private static final char HYPHEN_MINUS = '-';
// Various locales use this as minus sign
private static final char MINUS_SIGN = '\u2212';
// Slovenian uses this as minus sign (a bug?): http://unicode.org/cldr/trac/ticket/10050
private static final char EN_DASH = '\u2013';
private String mDecimalPointChars = DEFAULT_DECIMAL_POINT_CHARS;
private String mSignChars = DEFAULT_SIGN_CHARS;
private static final int SIGN = 1;
private static final int DECIMAL = 2;
@Override
protected char[] getAcceptedChars() {
return mAccepted;
}
/**
* The characters that are used in compatibility mode.
*
* @see KeyEvent#getMatch
* @see #getAcceptedChars
*/
private static final char[][] COMPATIBILITY_CHARACTERS = {
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' },
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '+' },
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.' },
{ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '+', '.' },
};
private boolean isSignChar(final char c) {
return mSignChars.indexOf(c) != -1;
}
private boolean isDecimalPointChar(final char c) {
return mDecimalPointChars.indexOf(c) != -1;
}
/**
* Allocates a DigitsKeyListener that accepts the ASCII digits 0 through 9.
*
* @deprecated Use {@link #DigitsKeyListener(Locale)} instead.
*/
@Deprecated
public DigitsKeyListener() {
this(null, false, false);
}
/**
* Allocates a DigitsKeyListener that accepts the ASCII digits 0 through 9, plus the ASCII plus
* or minus sign (only at the beginning) and/or the ASCII period ('.') as the decimal point
* (only one per field) if specified.
*
* @deprecated Use {@link #DigitsKeyListener(Locale, boolean, boolean)} instead.
*/
@Deprecated
public DigitsKeyListener(boolean sign, boolean decimal) {
this(null, sign, decimal);
}
public DigitsKeyListener(@Nullable Locale locale) {
this(locale, false, false);
}
private void setToCompat() {
mDecimalPointChars = DEFAULT_DECIMAL_POINT_CHARS;
mSignChars = DEFAULT_SIGN_CHARS;
final int kind = (mSign ? SIGN : 0) | (mDecimal ? DECIMAL : 0);
mAccepted = COMPATIBILITY_CHARACTERS[kind];
mNeedsAdvancedInput = false;
}
private void calculateNeedForAdvancedInput() {
final int kind = (mSign ? SIGN : 0) | (mDecimal ? DECIMAL : 0);
mNeedsAdvancedInput = !ArrayUtils.containsAll(COMPATIBILITY_CHARACTERS[kind], mAccepted);
}
// Takes a sign string and strips off its bidi controls, if any.
@NonNull
private static String stripBidiControls(@NonNull String sign) {
// For the sake of simplicity, we operate on code units, since all bidi controls are
// in the BMP. We also expect the string to be very short (almost always 1 character), so we
// don't need to use StringBuilder.
String result = "";
for (int i = 0; i < sign.length(); i++) {
final char c = sign.charAt(i);
if (!UCharacter.hasBinaryProperty(c, UProperty.BIDI_CONTROL)) {
if (result.isEmpty()) {
result = String.valueOf(c);
} else {
// This should happen very rarely, only if we have a multi-character sign,
// or a sign outside BMP.
result += c;
}
}
}
return result;
}
public DigitsKeyListener(@Nullable Locale locale, boolean sign, boolean decimal) {
mSign = sign;
mDecimal = decimal;
mStringMode = false;
mLocale = locale;
if (locale == null) {
setToCompat();
return;
}
LinkedHashSet<Character> chars = new LinkedHashSet<>();
final boolean success = NumberKeyListener.addDigits(chars, locale);
if (!success) {
setToCompat();
return;
}
if (sign || decimal) {
final DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
if (sign) {
final String minusString = stripBidiControls(symbols.getMinusSignString());
final String plusString = stripBidiControls(symbols.getPlusSignString());
if (minusString.length() > 1 || plusString.length() > 1) {
// non-BMP and multi-character signs are not supported.
setToCompat();
return;
}
final char minus = minusString.charAt(0);
final char plus = plusString.charAt(0);
chars.add(Character.valueOf(minus));
chars.add(Character.valueOf(plus));
mSignChars = "" + minus + plus;
if (minus == MINUS_SIGN || minus == EN_DASH) {
// If the minus sign is U+2212 MINUS SIGN or U+2013 EN DASH, we also need to
// accept the ASCII hyphen-minus.
chars.add(HYPHEN_MINUS);
mSignChars += HYPHEN_MINUS;
}
}
if (decimal) {
final String separatorString = symbols.getDecimalSeparatorString();
if (separatorString.length() > 1) {
// non-BMP and multi-character decimal separators are not supported.
setToCompat();
return;
}
final Character separatorChar = Character.valueOf(separatorString.charAt(0));
chars.add(separatorChar);
mDecimalPointChars = separatorChar.toString();
}
}
mAccepted = NumberKeyListener.collectionToArray(chars);
calculateNeedForAdvancedInput();
}
private DigitsKeyListener(@NonNull final String accepted) {
mSign = false;
mDecimal = false;
mStringMode = true;
mLocale = null;
mAccepted = new char[accepted.length()];
accepted.getChars(0, accepted.length(), mAccepted, 0);
// Theoretically we may need advanced input, but for backward compatibility, we don't change
// the input type.
mNeedsAdvancedInput = false;
}
/**
* Returns a DigitsKeyListener that accepts the ASCII digits 0 through 9.
*
* @deprecated Use {@link #getInstance(Locale)} instead.
*/
@Deprecated
@NonNull
public static DigitsKeyListener getInstance() {
return getInstance(false, false);
}
/**
* Returns a DigitsKeyListener that accepts the ASCII digits 0 through 9, plus the ASCII plus
* or minus sign (only at the beginning) and/or the ASCII period ('.') as the decimal point
* (only one per field) if specified.
*
* @deprecated Use {@link #getInstance(Locale, boolean, boolean)} instead.
*/
@Deprecated
@NonNull
public static DigitsKeyListener getInstance(boolean sign, boolean decimal) {
return getInstance(null, sign, decimal);
}
/**
* Returns a DigitsKeyListener that accepts the locale-appropriate digits.
*/
@NonNull
public static DigitsKeyListener getInstance(@Nullable Locale locale) {
return getInstance(locale, false, false);
}
private static final Object sLocaleCacheLock = new Object();
@GuardedBy("sLocaleCacheLock")
private static final HashMap<Locale, DigitsKeyListener[]> sLocaleInstanceCache =
new HashMap<>();
/**
* Returns a DigitsKeyListener that accepts the locale-appropriate digits, plus the
* locale-appropriate plus or minus sign (only at the beginning) and/or the locale-appropriate
* decimal separator (only one per field) if specified.
*/
@NonNull
public static DigitsKeyListener getInstance(
@Nullable Locale locale, boolean sign, boolean decimal) {
final int kind = (sign ? SIGN : 0) | (decimal ? DECIMAL : 0);
synchronized (sLocaleCacheLock) {
DigitsKeyListener[] cachedValue = sLocaleInstanceCache.get(locale);
if (cachedValue != null && cachedValue[kind] != null) {
return cachedValue[kind];
}
if (cachedValue == null) {
cachedValue = new DigitsKeyListener[4];
sLocaleInstanceCache.put(locale, cachedValue);
}
return cachedValue[kind] = new DigitsKeyListener(locale, sign, decimal);
}
}
private static final Object sStringCacheLock = new Object();
@GuardedBy("sStringCacheLock")
private static final HashMap<String, DigitsKeyListener> sStringInstanceCache = new HashMap<>();
/**
* Returns a DigitsKeyListener that accepts only the characters
* that appear in the specified String. Note that not all characters
* may be available on every keyboard.
*/
@NonNull
public static DigitsKeyListener getInstance(@NonNull String accepted) {
DigitsKeyListener result;
synchronized (sStringCacheLock) {
result = sStringInstanceCache.get(accepted);
if (result == null) {
result = new DigitsKeyListener(accepted);
sStringInstanceCache.put(accepted, result);
}
}
return result;
}
/**
* Returns a DigitsKeyListener based on an the settings of a existing DigitsKeyListener, with
* the locale modified.
*
* @hide
*/
@NonNull
public static DigitsKeyListener getInstance(
@Nullable Locale locale,
@NonNull DigitsKeyListener listener) {
if (listener.mStringMode) {
return listener; // string-mode DigitsKeyListeners have no locale.
} else {
return getInstance(locale, listener.mSign, listener.mDecimal);
}
}
/**
* Returns the input type for the listener.
*/
public int getInputType() {
int contentType;
if (mNeedsAdvancedInput) {
contentType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL;
} else {
contentType = InputType.TYPE_CLASS_NUMBER;
if (mSign) {
contentType |= InputType.TYPE_NUMBER_FLAG_SIGNED;
}
if (mDecimal) {
contentType |= InputType.TYPE_NUMBER_FLAG_DECIMAL;
}
}
return contentType;
}
@Override
public CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
CharSequence out = super.filter(source, start, end, dest, dstart, dend);
if (mSign == false && mDecimal == false) {
return out;
}
if (out != null) {
source = out;
start = 0;
end = out.length();
}
int sign = -1;
int decimal = -1;
int dlen = dest.length();
/*
* Find out if the existing text has a sign or decimal point characters.
*/
for (int i = 0; i < dstart; i++) {
char c = dest.charAt(i);
if (isSignChar(c)) {
sign = i;
} else if (isDecimalPointChar(c)) {
decimal = i;
}
}
for (int i = dend; i < dlen; i++) {
char c = dest.charAt(i);
if (isSignChar(c)) {
return ""; // Nothing can be inserted in front of a sign character.
} else if (isDecimalPointChar(c)) {
decimal = i;
}
}
/*
* If it does, we must strip them out from the source.
* In addition, a sign character must be the very first character,
* and nothing can be inserted before an existing sign character.
* Go in reverse order so the offsets are stable.
*/
SpannableStringBuilder stripped = null;
for (int i = end - 1; i >= start; i--) {
char c = source.charAt(i);
boolean strip = false;
if (isSignChar(c)) {
if (i != start || dstart != 0) {
strip = true;
} else if (sign >= 0) {
strip = true;
} else {
sign = i;
}
} else if (isDecimalPointChar(c)) {
if (decimal >= 0) {
strip = true;
} else {
decimal = i;
}
}
if (strip) {
if (end == start + 1) {
return ""; // Only one character, and it was stripped.
}
if (stripped == null) {
stripped = new SpannableStringBuilder(source, start, end);
}
stripped.delete(i - start, i + 1 - start);
}
}
if (stripped != null) {
return stripped;
} else if (out != null) {
return out;
} else {
return null;
}
}
}