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
17package android.text.method;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.icu.lang.UCharacter;
22import android.icu.lang.UProperty;
23import android.icu.text.DecimalFormatSymbols;
24import android.text.InputType;
25import android.text.SpannableStringBuilder;
26import android.text.Spanned;
27import android.view.KeyEvent;
28
29import com.android.internal.annotations.GuardedBy;
30import com.android.internal.util.ArrayUtils;
31
32import java.util.HashMap;
33import java.util.LinkedHashSet;
34import 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 */
43public 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
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
86    private boolean isSignChar(final char c) {
87        return mSignChars.indexOf(c) != -1;
88    }
89
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
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
112    public DigitsKeyListener(boolean sign, boolean decimal) {
113        this(null, sign, decimal);
114    }
115
116    public DigitsKeyListener(@Nullable Locale locale) {
117        this(locale, false, false);
118    }
119
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
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
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
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
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
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
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
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
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
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
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     */
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
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