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