1/*
2 * Copyright (C) 2013 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 com.android.inputmethod.latin.utils;
18
19import android.content.ContentValues;
20import android.content.Context;
21import android.content.res.AssetManager;
22import android.content.res.Resources;
23import android.text.TextUtils;
24import android.util.Log;
25import android.view.inputmethod.InputMethodSubtype;
26
27import com.android.inputmethod.annotations.UsedForTesting;
28import com.android.inputmethod.dictionarypack.UpdateHandler;
29import com.android.inputmethod.latin.AssetFileAddress;
30import com.android.inputmethod.latin.BinaryDictionaryGetter;
31import com.android.inputmethod.latin.R;
32import com.android.inputmethod.latin.RichInputMethodManager;
33import com.android.inputmethod.latin.common.FileUtils;
34import com.android.inputmethod.latin.common.LocaleUtils;
35import com.android.inputmethod.latin.define.DecoderSpecificConstants;
36import com.android.inputmethod.latin.makedict.DictionaryHeader;
37import com.android.inputmethod.latin.makedict.UnsupportedFormatException;
38import com.android.inputmethod.latin.settings.SpacingAndPunctuations;
39
40import java.io.File;
41import java.io.FilenameFilter;
42import java.io.IOException;
43import java.util.ArrayList;
44import java.util.Iterator;
45import java.util.List;
46import java.util.Locale;
47import java.util.concurrent.TimeUnit;
48
49import javax.annotation.Nonnull;
50import javax.annotation.Nullable;
51
52/**
53 * This class encapsulates the logic for the Latin-IME side of dictionary information management.
54 */
55public class DictionaryInfoUtils {
56    private static final String TAG = DictionaryInfoUtils.class.getSimpleName();
57    public static final String RESOURCE_PACKAGE_NAME = R.class.getPackage().getName();
58    private static final String DEFAULT_MAIN_DICT = "main";
59    private static final String MAIN_DICT_PREFIX = "main_";
60    private static final String DECODER_DICT_SUFFIX = DecoderSpecificConstants.DECODER_DICT_SUFFIX;
61    // 6 digits - unicode is limited to 21 bits
62    private static final int MAX_HEX_DIGITS_FOR_CODEPOINT = 6;
63
64    private static final String TEMP_DICT_FILE_SUB = UpdateHandler.TEMP_DICT_FILE_SUB;
65
66    public static class DictionaryInfo {
67        private static final String LOCALE_COLUMN = "locale";
68        private static final String WORDLISTID_COLUMN = "id";
69        private static final String LOCAL_FILENAME_COLUMN = "filename";
70        private static final String DESCRIPTION_COLUMN = "description";
71        private static final String DATE_COLUMN = "date";
72        private static final String FILESIZE_COLUMN = "filesize";
73        private static final String VERSION_COLUMN = "version";
74
75        @Nonnull public final String mId;
76        @Nonnull public final Locale mLocale;
77        @Nullable public final String mDescription;
78        @Nullable public final String mFilename;
79        public final long mFilesize;
80        public final long mModifiedTimeMillis;
81        public final int mVersion;
82
83        public DictionaryInfo(@Nonnull String id, @Nonnull Locale locale,
84                @Nullable String description, @Nullable String filename,
85                long filesize, long modifiedTimeMillis, int version) {
86            mId = id;
87            mLocale = locale;
88            mDescription = description;
89            mFilename = filename;
90            mFilesize = filesize;
91            mModifiedTimeMillis = modifiedTimeMillis;
92            mVersion = version;
93        }
94
95        public ContentValues toContentValues() {
96            final ContentValues values = new ContentValues();
97            values.put(WORDLISTID_COLUMN, mId);
98            values.put(LOCALE_COLUMN, mLocale.toString());
99            values.put(DESCRIPTION_COLUMN, mDescription);
100            values.put(LOCAL_FILENAME_COLUMN, mFilename != null ? mFilename : "");
101            values.put(DATE_COLUMN, TimeUnit.MILLISECONDS.toSeconds(mModifiedTimeMillis));
102            values.put(FILESIZE_COLUMN, mFilesize);
103            values.put(VERSION_COLUMN, mVersion);
104            return values;
105        }
106
107        @Override
108        public String toString() {
109            return "DictionaryInfo : Id = '" + mId
110                    + "' : Locale=" + mLocale
111                    + " : Version=" + mVersion;
112        }
113    }
114
115    private DictionaryInfoUtils() {
116        // Private constructor to forbid instantation of this helper class.
117    }
118
119    /**
120     * Returns whether we may want to use this character as part of a file name.
121     *
122     * This basically only accepts ascii letters and numbers, and rejects everything else.
123     */
124    private static boolean isFileNameCharacter(int codePoint) {
125        if (codePoint >= 0x30 && codePoint <= 0x39) return true; // Digit
126        if (codePoint >= 0x41 && codePoint <= 0x5A) return true; // Uppercase
127        if (codePoint >= 0x61 && codePoint <= 0x7A) return true; // Lowercase
128        return codePoint == '_'; // Underscore
129    }
130
131    /**
132     * Escapes a string for any characters that may be suspicious for a file or directory name.
133     *
134     * Concretely this does a sort of URL-encoding except it will encode everything that's not
135     * alphanumeric or underscore. (true URL-encoding leaves alone characters like '*', which
136     * we cannot allow here)
137     */
138    // TODO: create a unit test for this method
139    public static String replaceFileNameDangerousCharacters(final String name) {
140        // This assumes '%' is fully available as a non-separator, normal
141        // character in a file name. This is probably true for all file systems.
142        final StringBuilder sb = new StringBuilder();
143        final int nameLength = name.length();
144        for (int i = 0; i < nameLength; i = name.offsetByCodePoints(i, 1)) {
145            final int codePoint = name.codePointAt(i);
146            if (DictionaryInfoUtils.isFileNameCharacter(codePoint)) {
147                sb.appendCodePoint(codePoint);
148            } else {
149                sb.append(String.format((Locale)null, "%%%1$0" + MAX_HEX_DIGITS_FOR_CODEPOINT + "x",
150                        codePoint));
151            }
152        }
153        return sb.toString();
154    }
155
156    /**
157     * Helper method to get the top level cache directory.
158     */
159    private static String getWordListCacheDirectory(final Context context) {
160        return context.getFilesDir() + File.separator + "dicts";
161    }
162
163    /**
164     * Helper method to get the top level cache directory.
165     */
166    public static String getWordListStagingDirectory(final Context context) {
167        return context.getFilesDir() + File.separator + "staging";
168    }
169
170    /**
171     * Helper method to get the top level temp directory.
172     */
173    public static String getWordListTempDirectory(final Context context) {
174        return context.getFilesDir() + File.separator + "tmp";
175    }
176
177    /**
178     * Reverse escaping done by {@link #replaceFileNameDangerousCharacters(String)}.
179     */
180    @Nonnull
181    public static String getWordListIdFromFileName(@Nonnull final String fname) {
182        final StringBuilder sb = new StringBuilder();
183        final int fnameLength = fname.length();
184        for (int i = 0; i < fnameLength; i = fname.offsetByCodePoints(i, 1)) {
185            final int codePoint = fname.codePointAt(i);
186            if ('%' != codePoint) {
187                sb.appendCodePoint(codePoint);
188            } else {
189                // + 1 to pass the % sign
190                final int encodedCodePoint = Integer.parseInt(
191                        fname.substring(i + 1, i + 1 + MAX_HEX_DIGITS_FOR_CODEPOINT), 16);
192                i += MAX_HEX_DIGITS_FOR_CODEPOINT;
193                sb.appendCodePoint(encodedCodePoint);
194            }
195        }
196        return sb.toString();
197    }
198
199    /**
200     * Helper method to the list of cache directories, one for each distinct locale.
201     */
202    public static File[] getCachedDirectoryList(final Context context) {
203        return new File(DictionaryInfoUtils.getWordListCacheDirectory(context)).listFiles();
204    }
205
206    public static File[] getStagingDirectoryList(final Context context) {
207        return new File(DictionaryInfoUtils.getWordListStagingDirectory(context)).listFiles();
208    }
209
210    @Nullable
211    public static File[] getUnusedDictionaryList(final Context context) {
212        return context.getFilesDir().listFiles(new FilenameFilter() {
213            @Override
214            public boolean accept(File dir, String filename) {
215                return !TextUtils.isEmpty(filename) && filename.endsWith(".dict")
216                        && filename.contains(TEMP_DICT_FILE_SUB);
217            }
218        });
219    }
220
221    /**
222     * Returns the category for a given file name.
223     *
224     * This parses the file name, extracts the category, and returns it. See
225     * {@link #getMainDictId(Locale)} and {@link #isMainWordListId(String)}.
226     * @return The category as a string or null if it can't be found in the file name.
227     */
228    @Nullable
229    public static String getCategoryFromFileName(@Nonnull final String fileName) {
230        final String id = getWordListIdFromFileName(fileName);
231        final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
232        // An id is supposed to be in format category:locale, so splitting on the separator
233        // should yield a 2-elements array
234        if (2 != idArray.length) {
235            return null;
236        }
237        return idArray[0];
238    }
239
240    /**
241     * Find out the cache directory associated with a specific locale.
242     */
243    public static String getCacheDirectoryForLocale(final String locale, final Context context) {
244        final String relativeDirectoryName = replaceFileNameDangerousCharacters(locale);
245        final String absoluteDirectoryName = getWordListCacheDirectory(context) + File.separator
246                + relativeDirectoryName;
247        final File directory = new File(absoluteDirectoryName);
248        if (!directory.exists()) {
249            if (!directory.mkdirs()) {
250                Log.e(TAG, "Could not create the directory for locale" + locale);
251            }
252        }
253        return absoluteDirectoryName;
254    }
255
256    /**
257     * Generates a file name for the id and locale passed as an argument.
258     *
259     * In the current implementation the file name returned will always be unique for
260     * any id/locale pair, but please do not expect that the id can be the same for
261     * different dictionaries with different locales. An id should be unique for any
262     * dictionary.
263     * The file name is pretty much an URL-encoded version of the id inside a directory
264     * named like the locale, except it will also escape characters that look dangerous
265     * to some file systems.
266     * @param id the id of the dictionary for which to get a file name
267     * @param locale the locale for which to get the file name as a string
268     * @param context the context to use for getting the directory
269     * @return the name of the file to be created
270     */
271    public static String getCacheFileName(String id, String locale, Context context) {
272        final String fileName = replaceFileNameDangerousCharacters(id);
273        return getCacheDirectoryForLocale(locale, context) + File.separator + fileName;
274    }
275
276    public static String getStagingFileName(String id, String locale, Context context) {
277        final String stagingDirectory = getWordListStagingDirectory(context);
278        // create the directory if it does not exist.
279        final File directory = new File(stagingDirectory);
280        if (!directory.exists()) {
281            if (!directory.mkdirs()) {
282                Log.e(TAG, "Could not create the staging directory.");
283            }
284        }
285        // e.g. id="main:en_in", locale ="en_IN"
286        final String fileName = replaceFileNameDangerousCharacters(
287                locale + TEMP_DICT_FILE_SUB + id);
288        return stagingDirectory + File.separator + fileName;
289    }
290
291    public static void moveStagingFilesIfExists(Context context) {
292        final File[] stagingFiles = DictionaryInfoUtils.getStagingDirectoryList(context);
293        if (stagingFiles != null && stagingFiles.length > 0) {
294            for (final File stagingFile : stagingFiles) {
295                final String fileName = stagingFile.getName();
296                final int index = fileName.indexOf(TEMP_DICT_FILE_SUB);
297                if (index == -1) {
298                    // This should never happen.
299                    Log.e(TAG, "Staging file does not have ___ substring.");
300                    continue;
301                }
302                final String[] localeAndFileId = fileName.split(TEMP_DICT_FILE_SUB);
303                if (localeAndFileId.length != 2) {
304                    Log.e(TAG, String.format("malformed staging file %s. Deleting.",
305                            stagingFile.getAbsoluteFile()));
306                    stagingFile.delete();
307                    continue;
308                }
309
310                final String locale = localeAndFileId[0];
311                // already escaped while moving to staging.
312                final String fileId = localeAndFileId[1];
313                final String cacheDirectoryForLocale = getCacheDirectoryForLocale(locale, context);
314                final String cacheFilename = cacheDirectoryForLocale + File.separator + fileId;
315                final File cacheFile = new File(cacheFilename);
316                // move the staging file to cache file.
317                if (!FileUtils.renameTo(stagingFile, cacheFile)) {
318                    Log.e(TAG, String.format("Failed to rename from %s to %s.",
319                            stagingFile.getAbsoluteFile(), cacheFile.getAbsoluteFile()));
320                }
321            }
322        }
323    }
324
325    public static boolean isMainWordListId(final String id) {
326        final String[] idArray = id.split(BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR);
327        // An id is supposed to be in format category:locale, so splitting on the separator
328        // should yield a 2-elements array
329        if (2 != idArray.length) {
330            return false;
331        }
332        return BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY.equals(idArray[0]);
333    }
334
335    /**
336     * Find out whether a dictionary is available for this locale.
337     * @param context the context on which to check resources.
338     * @param locale the locale to check for.
339     * @return whether a (non-placeholder) dictionary is available or not.
340     */
341    public static boolean isDictionaryAvailable(final Context context, final Locale locale) {
342        final Resources res = context.getResources();
343        return 0 != getMainDictionaryResourceIdIfAvailableForLocale(res, locale);
344    }
345
346    /**
347     * Helper method to return a dictionary res id for a locale, or 0 if none.
348     * @param res resources for the app
349     * @param locale dictionary locale
350     * @return main dictionary resource id
351     */
352    public static int getMainDictionaryResourceIdIfAvailableForLocale(final Resources res,
353            final Locale locale) {
354        int resId;
355        // Try to find main_language_country dictionary.
356        if (!locale.getCountry().isEmpty()) {
357            final String dictLanguageCountry = MAIN_DICT_PREFIX
358                    + locale.toString().toLowerCase(Locale.ROOT) + DECODER_DICT_SUFFIX;
359            if ((resId = res.getIdentifier(
360                    dictLanguageCountry, "raw", RESOURCE_PACKAGE_NAME)) != 0) {
361                return resId;
362            }
363        }
364
365        // Try to find main_language dictionary.
366        final String dictLanguage = MAIN_DICT_PREFIX + locale.getLanguage() + DECODER_DICT_SUFFIX;
367        if ((resId = res.getIdentifier(dictLanguage, "raw", RESOURCE_PACKAGE_NAME)) != 0) {
368            return resId;
369        }
370
371        // Not found, return 0
372        return 0;
373    }
374
375    /**
376     * Returns a main dictionary resource id
377     * @param res resources for the app
378     * @param locale dictionary locale
379     * @return main dictionary resource id
380     */
381    public static int getMainDictionaryResourceId(final Resources res, final Locale locale) {
382        int resourceId = getMainDictionaryResourceIdIfAvailableForLocale(res, locale);
383        if (0 != resourceId) {
384            return resourceId;
385        }
386        return res.getIdentifier(DEFAULT_MAIN_DICT + DecoderSpecificConstants.DECODER_DICT_SUFFIX,
387                "raw", RESOURCE_PACKAGE_NAME);
388    }
389
390    /**
391     * Returns the id associated with the main word list for a specified locale.
392     *
393     * Word lists stored in Android Keyboard's resources are referred to as the "main"
394     * word lists. Since they can be updated like any other list, we need to assign a
395     * unique ID to them. This ID is just the name of the language (locale-wise) they
396     * are for, and this method returns this ID.
397     */
398    public static String getMainDictId(@Nonnull final Locale locale) {
399        // This works because we don't include by default different dictionaries for
400        // different countries. This actually needs to return the id that we would
401        // like to use for word lists included in resources, and the following is okay.
402        return BinaryDictionaryGetter.MAIN_DICTIONARY_CATEGORY +
403                BinaryDictionaryGetter.ID_CATEGORY_SEPARATOR + locale.toString().toLowerCase();
404    }
405
406    public static DictionaryHeader getDictionaryFileHeaderOrNull(final File file,
407            final long offset, final long length) {
408        try {
409            final DictionaryHeader header =
410                    BinaryDictionaryUtils.getHeaderWithOffsetAndLength(file, offset, length);
411            return header;
412        } catch (UnsupportedFormatException e) {
413            return null;
414        } catch (IOException e) {
415            return null;
416        }
417    }
418
419    /**
420     * Returns information of the dictionary.
421     *
422     * @param fileAddress the asset dictionary file address.
423     * @param locale Locale for this file.
424     * @return information of the specified dictionary.
425     */
426    private static DictionaryInfo createDictionaryInfoFromFileAddress(
427            @Nonnull final AssetFileAddress fileAddress, final Locale locale) {
428        final String id = getMainDictId(locale);
429        final int version = DictionaryHeaderUtils.getContentVersion(fileAddress);
430        final String description = SubtypeLocaleUtils
431                .getSubtypeLocaleDisplayName(locale.toString());
432        // Do not store the filename on db as it will try to move the filename from db to the
433        // cached directory. If the filename is already in cached directory, this is not
434        // necessary.
435        final String filenameToStoreOnDb = null;
436        return new DictionaryInfo(id, locale, description, filenameToStoreOnDb,
437                fileAddress.mLength, new File(fileAddress.mFilename).lastModified(), version);
438    }
439
440    /**
441     * Returns the information of the dictionary for the given {@link AssetFileAddress}.
442     * If the file is corrupted or a pre-fava file, then the file gets deleted and the null
443     * value is returned.
444     */
445    @Nullable
446    private static DictionaryInfo createDictionaryInfoForUnCachedFile(
447            @Nonnull final AssetFileAddress fileAddress, final Locale locale) {
448        final String id = getMainDictId(locale);
449        final int version = DictionaryHeaderUtils.getContentVersion(fileAddress);
450
451        if (version == -1) {
452            // Purge the pre-fava/corrupted unused dictionaires.
453            fileAddress.deleteUnderlyingFile();
454            return null;
455        }
456
457        final String description = SubtypeLocaleUtils
458                .getSubtypeLocaleDisplayName(locale.toString());
459
460        final File unCachedFile = new File(fileAddress.mFilename);
461        // Store just the filename and not the full path.
462        final String filenameToStoreOnDb = unCachedFile.getName();
463        return new DictionaryInfo(id, locale, description, filenameToStoreOnDb, fileAddress.mLength,
464                unCachedFile.lastModified(), version);
465    }
466
467    /**
468     * Returns dictionary information for the given locale.
469     */
470    private static DictionaryInfo createDictionaryInfoFromLocale(Locale locale) {
471        final String id = getMainDictId(locale);
472        final int version = -1;
473        final String description = SubtypeLocaleUtils
474                .getSubtypeLocaleDisplayName(locale.toString());
475        return new DictionaryInfo(id, locale, description, null, 0L, 0L, version);
476    }
477
478    private static void addOrUpdateDictInfo(final ArrayList<DictionaryInfo> dictList,
479            final DictionaryInfo newElement) {
480        final Iterator<DictionaryInfo> iter = dictList.iterator();
481        while (iter.hasNext()) {
482            final DictionaryInfo thisDictInfo = iter.next();
483            if (thisDictInfo.mLocale.equals(newElement.mLocale)) {
484                if (newElement.mVersion <= thisDictInfo.mVersion) {
485                    return;
486                }
487                iter.remove();
488            }
489        }
490        dictList.add(newElement);
491    }
492
493    public static ArrayList<DictionaryInfo> getCurrentDictionaryFileNameAndVersionInfo(
494            final Context context) {
495        final ArrayList<DictionaryInfo> dictList = new ArrayList<>();
496
497        // Retrieve downloaded dictionaries from cached directories
498        final File[] directoryList = getCachedDirectoryList(context);
499        if (null != directoryList) {
500            for (final File directory : directoryList) {
501                final String localeString = getWordListIdFromFileName(directory.getName());
502                final File[] dicts = BinaryDictionaryGetter.getCachedWordLists(
503                        localeString, context);
504                for (final File dict : dicts) {
505                    final String wordListId = getWordListIdFromFileName(dict.getName());
506                    if (!DictionaryInfoUtils.isMainWordListId(wordListId)) {
507                        continue;
508                    }
509                    final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
510                    final AssetFileAddress fileAddress = AssetFileAddress.makeFromFile(dict);
511                    final DictionaryInfo dictionaryInfo =
512                            createDictionaryInfoFromFileAddress(fileAddress, locale);
513                    // Protect against cases of a less-specific dictionary being found, like an
514                    // en dictionary being used for an en_US locale. In this case, the en dictionary
515                    // should be used for en_US but discounted for listing purposes.
516                    if (dictionaryInfo == null || !dictionaryInfo.mLocale.equals(locale)) {
517                        continue;
518                    }
519                    addOrUpdateDictInfo(dictList, dictionaryInfo);
520                }
521            }
522        }
523
524        // Retrieve downloaded dictionaries from the unused dictionaries.
525        File[] unusedDictionaryList = getUnusedDictionaryList(context);
526        if (unusedDictionaryList != null) {
527            for (File dictionaryFile : unusedDictionaryList) {
528                String fileName = dictionaryFile.getName();
529                int index = fileName.indexOf(TEMP_DICT_FILE_SUB);
530                if (index == -1) {
531                    continue;
532                }
533                String locale = fileName.substring(0, index);
534                DictionaryInfo dictionaryInfo = createDictionaryInfoForUnCachedFile(
535                        AssetFileAddress.makeFromFile(dictionaryFile),
536                        LocaleUtils.constructLocaleFromString(locale));
537                if (dictionaryInfo != null) {
538                    addOrUpdateDictInfo(dictList, dictionaryInfo);
539                }
540            }
541        }
542
543        // Retrieve files from assets
544        final Resources resources = context.getResources();
545        final AssetManager assets = resources.getAssets();
546        for (final String localeString : assets.getLocales()) {
547            final Locale locale = LocaleUtils.constructLocaleFromString(localeString);
548            final int resourceId =
549                    DictionaryInfoUtils.getMainDictionaryResourceIdIfAvailableForLocale(
550                            context.getResources(), locale);
551            if (0 == resourceId) {
552                continue;
553            }
554            final AssetFileAddress fileAddress =
555                    BinaryDictionaryGetter.loadFallbackResource(context, resourceId);
556            final DictionaryInfo dictionaryInfo = createDictionaryInfoFromFileAddress(fileAddress,
557                    locale);
558            // Protect against cases of a less-specific dictionary being found, like an
559            // en dictionary being used for an en_US locale. In this case, the en dictionary
560            // should be used for en_US but discounted for listing purposes.
561            // TODO: Remove dictionaryInfo == null when the static LMs have the headers.
562            if (dictionaryInfo == null || !dictionaryInfo.mLocale.equals(locale)) {
563                continue;
564            }
565            addOrUpdateDictInfo(dictList, dictionaryInfo);
566        }
567
568        // Generate the dictionary information from  the enabled subtypes. This will not
569        // overwrite the real records.
570        RichInputMethodManager.init(context);
571        List<InputMethodSubtype> enabledSubtypes = RichInputMethodManager
572                .getInstance().getMyEnabledInputMethodSubtypeList(true);
573        for (InputMethodSubtype subtype : enabledSubtypes) {
574            Locale locale = LocaleUtils.constructLocaleFromString(subtype.getLocale());
575            DictionaryInfo dictionaryInfo = createDictionaryInfoFromLocale(locale);
576            addOrUpdateDictInfo(dictList, dictionaryInfo);
577        }
578
579        return dictList;
580    }
581
582    @UsedForTesting
583    public static boolean looksValidForDictionaryInsertion(final CharSequence text,
584            final SpacingAndPunctuations spacingAndPunctuations) {
585        if (TextUtils.isEmpty(text)) {
586            return false;
587        }
588        final int length = text.length();
589        if (length > DecoderSpecificConstants.DICTIONARY_MAX_WORD_LENGTH) {
590            return false;
591        }
592        int i = 0;
593        int digitCount = 0;
594        while (i < length) {
595            final int codePoint = Character.codePointAt(text, i);
596            final int charCount = Character.charCount(codePoint);
597            i += charCount;
598            if (Character.isDigit(codePoint)) {
599                // Count digits: see below
600                digitCount += charCount;
601                continue;
602            }
603            if (!spacingAndPunctuations.isWordCodePoint(codePoint)) {
604                return false;
605            }
606        }
607        // We reject strings entirely comprised of digits to avoid using PIN codes or credit
608        // card numbers. It would come in handy for word prediction though; a good example is
609        // when writing one's address where the street number is usually quite discriminative,
610        // as well as the postal code.
611        return digitCount < length;
612    }
613}
614