1 /* 2 * Copyright (C) 2006 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.text.method; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.icu.lang.UCharacter; 22 import android.icu.lang.UProperty; 23 import android.icu.text.DecimalFormatSymbols; 24 import android.text.InputType; 25 import android.text.SpannableStringBuilder; 26 import android.text.Spanned; 27 import android.view.KeyEvent; 28 29 import com.android.internal.annotations.GuardedBy; 30 import com.android.internal.util.ArrayUtils; 31 32 import java.util.HashMap; 33 import java.util.LinkedHashSet; 34 import java.util.Locale; 35 36 /** 37 * For digits-only text entry 38 * <p></p> 39 * As for all implementations of {@link KeyListener}, this class is only concerned 40 * with hardware keyboards. Software input methods have no obligation to trigger 41 * the methods in this class. 42 */ 43 public class DigitsKeyListener extends NumberKeyListener 44 { 45 private char[] mAccepted; 46 private boolean mNeedsAdvancedInput; 47 private final boolean mSign; 48 private final boolean mDecimal; 49 private final boolean mStringMode; 50 @Nullable 51 private final Locale mLocale; 52 53 private static final String DEFAULT_DECIMAL_POINT_CHARS = "."; 54 private static final String DEFAULT_SIGN_CHARS = "-+"; 55 56 private static final char HYPHEN_MINUS = '-'; 57 // Various locales use this as minus sign 58 private static final char MINUS_SIGN = '\u2212'; 59 // Slovenian uses this as minus sign (a bug?): http://unicode.org/cldr/trac/ticket/10050 60 private static final char EN_DASH = '\u2013'; 61 62 private String mDecimalPointChars = DEFAULT_DECIMAL_POINT_CHARS; 63 private String mSignChars = DEFAULT_SIGN_CHARS; 64 65 private static final int SIGN = 1; 66 private static final int DECIMAL = 2; 67 68 @Override getAcceptedChars()69 protected char[] getAcceptedChars() { 70 return mAccepted; 71 } 72 73 /** 74 * The characters that are used in compatibility mode. 75 * 76 * @see KeyEvent#getMatch 77 * @see #getAcceptedChars 78 */ 79 private static final char[][] COMPATIBILITY_CHARACTERS = { 80 { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }, 81 { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '+' }, 82 { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.' }, 83 { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '+', '.' }, 84 }; 85 isSignChar(final char c)86 private boolean isSignChar(final char c) { 87 return mSignChars.indexOf(c) != -1; 88 } 89 isDecimalPointChar(final char c)90 private boolean isDecimalPointChar(final char c) { 91 return mDecimalPointChars.indexOf(c) != -1; 92 } 93 94 /** 95 * Allocates a DigitsKeyListener that accepts the ASCII digits 0 through 9. 96 * 97 * @deprecated Use {@link #DigitsKeyListener(Locale)} instead. 98 */ 99 @Deprecated DigitsKeyListener()100 public DigitsKeyListener() { 101 this(null, false, false); 102 } 103 104 /** 105 * Allocates a DigitsKeyListener that accepts the ASCII digits 0 through 9, plus the ASCII plus 106 * or minus sign (only at the beginning) and/or the ASCII period ('.') as the decimal point 107 * (only one per field) if specified. 108 * 109 * @deprecated Use {@link #DigitsKeyListener(Locale, boolean, boolean)} instead. 110 */ 111 @Deprecated DigitsKeyListener(boolean sign, boolean decimal)112 public DigitsKeyListener(boolean sign, boolean decimal) { 113 this(null, sign, decimal); 114 } 115 DigitsKeyListener(@ullable Locale locale)116 public DigitsKeyListener(@Nullable Locale locale) { 117 this(locale, false, false); 118 } 119 setToCompat()120 private void setToCompat() { 121 mDecimalPointChars = DEFAULT_DECIMAL_POINT_CHARS; 122 mSignChars = DEFAULT_SIGN_CHARS; 123 final int kind = (mSign ? SIGN : 0) | (mDecimal ? DECIMAL : 0); 124 mAccepted = COMPATIBILITY_CHARACTERS[kind]; 125 mNeedsAdvancedInput = false; 126 } 127 calculateNeedForAdvancedInput()128 private void calculateNeedForAdvancedInput() { 129 final int kind = (mSign ? SIGN : 0) | (mDecimal ? DECIMAL : 0); 130 mNeedsAdvancedInput = !ArrayUtils.containsAll(COMPATIBILITY_CHARACTERS[kind], mAccepted); 131 } 132 133 // Takes a sign string and strips off its bidi controls, if any. 134 @NonNull stripBidiControls(@onNull String sign)135 private static String stripBidiControls(@NonNull String sign) { 136 // For the sake of simplicity, we operate on code units, since all bidi controls are 137 // in the BMP. We also expect the string to be very short (almost always 1 character), so we 138 // don't need to use StringBuilder. 139 String result = ""; 140 for (int i = 0; i < sign.length(); i++) { 141 final char c = sign.charAt(i); 142 if (!UCharacter.hasBinaryProperty(c, UProperty.BIDI_CONTROL)) { 143 if (result.isEmpty()) { 144 result = String.valueOf(c); 145 } else { 146 // This should happen very rarely, only if we have a multi-character sign, 147 // or a sign outside BMP. 148 result += c; 149 } 150 } 151 } 152 return result; 153 } 154 DigitsKeyListener(@ullable Locale locale, boolean sign, boolean decimal)155 public DigitsKeyListener(@Nullable Locale locale, boolean sign, boolean decimal) { 156 mSign = sign; 157 mDecimal = decimal; 158 mStringMode = false; 159 mLocale = locale; 160 if (locale == null) { 161 setToCompat(); 162 return; 163 } 164 LinkedHashSet<Character> chars = new LinkedHashSet<>(); 165 final boolean success = NumberKeyListener.addDigits(chars, locale); 166 if (!success) { 167 setToCompat(); 168 return; 169 } 170 if (sign || decimal) { 171 final DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale); 172 if (sign) { 173 final String minusString = stripBidiControls(symbols.getMinusSignString()); 174 final String plusString = stripBidiControls(symbols.getPlusSignString()); 175 if (minusString.length() > 1 || plusString.length() > 1) { 176 // non-BMP and multi-character signs are not supported. 177 setToCompat(); 178 return; 179 } 180 final char minus = minusString.charAt(0); 181 final char plus = plusString.charAt(0); 182 chars.add(Character.valueOf(minus)); 183 chars.add(Character.valueOf(plus)); 184 mSignChars = "" + minus + plus; 185 186 if (minus == MINUS_SIGN || minus == EN_DASH) { 187 // If the minus sign is U+2212 MINUS SIGN or U+2013 EN DASH, we also need to 188 // accept the ASCII hyphen-minus. 189 chars.add(HYPHEN_MINUS); 190 mSignChars += HYPHEN_MINUS; 191 } 192 } 193 if (decimal) { 194 final String separatorString = symbols.getDecimalSeparatorString(); 195 if (separatorString.length() > 1) { 196 // non-BMP and multi-character decimal separators are not supported. 197 setToCompat(); 198 return; 199 } 200 final Character separatorChar = Character.valueOf(separatorString.charAt(0)); 201 chars.add(separatorChar); 202 mDecimalPointChars = separatorChar.toString(); 203 } 204 } 205 mAccepted = NumberKeyListener.collectionToArray(chars); 206 calculateNeedForAdvancedInput(); 207 } 208 DigitsKeyListener(@onNull final String accepted)209 private DigitsKeyListener(@NonNull final String accepted) { 210 mSign = false; 211 mDecimal = false; 212 mStringMode = true; 213 mLocale = null; 214 mAccepted = new char[accepted.length()]; 215 accepted.getChars(0, accepted.length(), mAccepted, 0); 216 // Theoretically we may need advanced input, but for backward compatibility, we don't change 217 // the input type. 218 mNeedsAdvancedInput = false; 219 } 220 221 /** 222 * Returns a DigitsKeyListener that accepts the ASCII digits 0 through 9. 223 * 224 * @deprecated Use {@link #getInstance(Locale)} instead. 225 */ 226 @Deprecated 227 @NonNull getInstance()228 public static DigitsKeyListener getInstance() { 229 return getInstance(false, false); 230 } 231 232 /** 233 * Returns a DigitsKeyListener that accepts the ASCII digits 0 through 9, plus the ASCII plus 234 * or minus sign (only at the beginning) and/or the ASCII period ('.') as the decimal point 235 * (only one per field) if specified. 236 * 237 * @deprecated Use {@link #getInstance(Locale, boolean, boolean)} instead. 238 */ 239 @Deprecated 240 @NonNull getInstance(boolean sign, boolean decimal)241 public static DigitsKeyListener getInstance(boolean sign, boolean decimal) { 242 return getInstance(null, sign, decimal); 243 } 244 245 /** 246 * Returns a DigitsKeyListener that accepts the locale-appropriate digits. 247 */ 248 @NonNull getInstance(@ullable Locale locale)249 public static DigitsKeyListener getInstance(@Nullable Locale locale) { 250 return getInstance(locale, false, false); 251 } 252 253 private static final Object sLocaleCacheLock = new Object(); 254 @GuardedBy("sLocaleCacheLock") 255 private static final HashMap<Locale, DigitsKeyListener[]> sLocaleInstanceCache = 256 new HashMap<>(); 257 258 /** 259 * Returns a DigitsKeyListener that accepts the locale-appropriate digits, plus the 260 * locale-appropriate plus or minus sign (only at the beginning) and/or the locale-appropriate 261 * decimal separator (only one per field) if specified. 262 */ 263 @NonNull getInstance( @ullable Locale locale, boolean sign, boolean decimal)264 public static DigitsKeyListener getInstance( 265 @Nullable Locale locale, boolean sign, boolean decimal) { 266 final int kind = (sign ? SIGN : 0) | (decimal ? DECIMAL : 0); 267 synchronized (sLocaleCacheLock) { 268 DigitsKeyListener[] cachedValue = sLocaleInstanceCache.get(locale); 269 if (cachedValue != null && cachedValue[kind] != null) { 270 return cachedValue[kind]; 271 } 272 if (cachedValue == null) { 273 cachedValue = new DigitsKeyListener[4]; 274 sLocaleInstanceCache.put(locale, cachedValue); 275 } 276 return cachedValue[kind] = new DigitsKeyListener(locale, sign, decimal); 277 } 278 } 279 280 private static final Object sStringCacheLock = new Object(); 281 @GuardedBy("sStringCacheLock") 282 private static final HashMap<String, DigitsKeyListener> sStringInstanceCache = new HashMap<>(); 283 284 /** 285 * Returns a DigitsKeyListener that accepts only the characters 286 * that appear in the specified String. Note that not all characters 287 * may be available on every keyboard. 288 */ 289 @NonNull getInstance(@onNull String accepted)290 public static DigitsKeyListener getInstance(@NonNull String accepted) { 291 DigitsKeyListener result; 292 synchronized (sStringCacheLock) { 293 result = sStringInstanceCache.get(accepted); 294 if (result == null) { 295 result = new DigitsKeyListener(accepted); 296 sStringInstanceCache.put(accepted, result); 297 } 298 } 299 return result; 300 } 301 302 /** 303 * Returns a DigitsKeyListener based on an the settings of a existing DigitsKeyListener, with 304 * the locale modified. 305 * 306 * @hide 307 */ 308 @NonNull getInstance( @ullable Locale locale, @NonNull DigitsKeyListener listener)309 public static DigitsKeyListener getInstance( 310 @Nullable Locale locale, 311 @NonNull DigitsKeyListener listener) { 312 if (listener.mStringMode) { 313 return listener; // string-mode DigitsKeyListeners have no locale. 314 } else { 315 return getInstance(locale, listener.mSign, listener.mDecimal); 316 } 317 } 318 319 /** 320 * Returns the input type for the listener. 321 */ getInputType()322 public int getInputType() { 323 int contentType; 324 if (mNeedsAdvancedInput) { 325 contentType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; 326 } else { 327 contentType = InputType.TYPE_CLASS_NUMBER; 328 if (mSign) { 329 contentType |= InputType.TYPE_NUMBER_FLAG_SIGNED; 330 } 331 if (mDecimal) { 332 contentType |= InputType.TYPE_NUMBER_FLAG_DECIMAL; 333 } 334 } 335 return contentType; 336 } 337 338 @Override filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend)339 public CharSequence filter(CharSequence source, int start, int end, 340 Spanned dest, int dstart, int dend) { 341 CharSequence out = super.filter(source, start, end, dest, dstart, dend); 342 343 if (mSign == false && mDecimal == false) { 344 return out; 345 } 346 347 if (out != null) { 348 source = out; 349 start = 0; 350 end = out.length(); 351 } 352 353 int sign = -1; 354 int decimal = -1; 355 int dlen = dest.length(); 356 357 /* 358 * Find out if the existing text has a sign or decimal point characters. 359 */ 360 361 for (int i = 0; i < dstart; i++) { 362 char c = dest.charAt(i); 363 364 if (isSignChar(c)) { 365 sign = i; 366 } else if (isDecimalPointChar(c)) { 367 decimal = i; 368 } 369 } 370 for (int i = dend; i < dlen; i++) { 371 char c = dest.charAt(i); 372 373 if (isSignChar(c)) { 374 return ""; // Nothing can be inserted in front of a sign character. 375 } else if (isDecimalPointChar(c)) { 376 decimal = i; 377 } 378 } 379 380 /* 381 * If it does, we must strip them out from the source. 382 * In addition, a sign character must be the very first character, 383 * and nothing can be inserted before an existing sign character. 384 * Go in reverse order so the offsets are stable. 385 */ 386 387 SpannableStringBuilder stripped = null; 388 389 for (int i = end - 1; i >= start; i--) { 390 char c = source.charAt(i); 391 boolean strip = false; 392 393 if (isSignChar(c)) { 394 if (i != start || dstart != 0) { 395 strip = true; 396 } else if (sign >= 0) { 397 strip = true; 398 } else { 399 sign = i; 400 } 401 } else if (isDecimalPointChar(c)) { 402 if (decimal >= 0) { 403 strip = true; 404 } else { 405 decimal = i; 406 } 407 } 408 409 if (strip) { 410 if (end == start + 1) { 411 return ""; // Only one character, and it was stripped. 412 } 413 414 if (stripped == null) { 415 stripped = new SpannableStringBuilder(source, start, end); 416 } 417 418 stripped.delete(i - start, i + 1 - start); 419 } 420 } 421 422 if (stripped != null) { 423 return stripped; 424 } else if (out != null) { 425 return out; 426 } else { 427 return null; 428 } 429 } 430 } 431