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 17 package com.android.inputmethod.latin.utils; 18 19 import android.content.ContentValues; 20 import android.content.Context; 21 import android.content.res.AssetManager; 22 import android.content.res.Resources; 23 import android.text.TextUtils; 24 import android.util.Log; 25 import android.view.inputmethod.InputMethodSubtype; 26 27 import com.android.inputmethod.annotations.UsedForTesting; 28 import com.android.inputmethod.dictionarypack.UpdateHandler; 29 import com.android.inputmethod.latin.AssetFileAddress; 30 import com.android.inputmethod.latin.BinaryDictionaryGetter; 31 import com.android.inputmethod.latin.R; 32 import com.android.inputmethod.latin.RichInputMethodManager; 33 import com.android.inputmethod.latin.common.FileUtils; 34 import com.android.inputmethod.latin.common.LocaleUtils; 35 import com.android.inputmethod.latin.define.DecoderSpecificConstants; 36 import com.android.inputmethod.latin.makedict.DictionaryHeader; 37 import com.android.inputmethod.latin.makedict.UnsupportedFormatException; 38 import com.android.inputmethod.latin.settings.SpacingAndPunctuations; 39 40 import java.io.File; 41 import java.io.FilenameFilter; 42 import java.io.IOException; 43 import java.util.ArrayList; 44 import java.util.Iterator; 45 import java.util.List; 46 import java.util.Locale; 47 import java.util.concurrent.TimeUnit; 48 49 import javax.annotation.Nonnull; 50 import javax.annotation.Nullable; 51 52 /** 53 * This class encapsulates the logic for the Latin-IME side of dictionary information management. 54 */ 55 public 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