SpellChecker.java revision 653d3a27878d5358b4a91518a756f6b9b3407b07
1/* 2 * Copyright (C) 2011 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.widget; 18 19import android.content.Context; 20import android.text.Editable; 21import android.text.Selection; 22import android.text.SpannableStringBuilder; 23import android.text.Spanned; 24import android.text.method.WordIterator; 25import android.text.style.SpellCheckSpan; 26import android.text.style.SuggestionSpan; 27import android.view.textservice.SpellCheckerSession; 28import android.view.textservice.SpellCheckerSession.SpellCheckerSessionListener; 29import android.view.textservice.SuggestionsInfo; 30import android.view.textservice.TextInfo; 31import android.view.textservice.TextServicesManager; 32 33import com.android.internal.util.ArrayUtils; 34 35import java.text.BreakIterator; 36import java.util.Locale; 37 38 39/** 40 * Helper class for TextView. Bridge between the TextView and the Dictionnary service. 41 * 42 * @hide 43 */ 44public class SpellChecker implements SpellCheckerSessionListener { 45 46 private final static int MAX_SPELL_BATCH_SIZE = 50; 47 48 private final TextView mTextView; 49 50 SpellCheckerSession mSpellCheckerSession; 51 final int mCookie; 52 53 // Paired arrays for the (id, spellCheckSpan) pair. A negative id means the associated 54 // SpellCheckSpan has been recycled and can be-reused. 55 // Contains null SpellCheckSpans after index mLength. 56 private int[] mIds; 57 private SpellCheckSpan[] mSpellCheckSpans; 58 // The mLength first elements of the above arrays have been initialized 59 private int mLength; 60 61 // Parsers on chunck of text, cutting text into words that will be checked 62 private SpellParser[] mSpellParsers = new SpellParser[0]; 63 64 private int mSpanSequenceCounter = 0; 65 66 private Locale mCurrentLocale; 67 68 // Shared by all SpellParsers. Cannot be shared with TextView since it may be used 69 // concurrently due to the asynchronous nature of onGetSuggestions. 70 private WordIterator mWordIterator; 71 72 private TextServicesManager mTextServicesManager; 73 74 public SpellChecker(TextView textView) { 75 mTextView = textView; 76 77 // Arbitrary: these arrays will automatically double their sizes on demand 78 final int size = ArrayUtils.idealObjectArraySize(1); 79 mIds = new int[size]; 80 mSpellCheckSpans = new SpellCheckSpan[size]; 81 82 setLocale(mTextView.getTextServicesLocale()); 83 84 mCookie = hashCode(); 85 } 86 87 private void resetSession() { 88 closeSession(); 89 90 mTextServicesManager = (TextServicesManager) mTextView.getContext(). 91 getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE); 92 if (!mTextServicesManager.isSpellCheckerEnabled()) { 93 mSpellCheckerSession = null; 94 } else { 95 mSpellCheckerSession = mTextServicesManager.newSpellCheckerSession( 96 null /* Bundle not currently used by the textServicesManager */, 97 mCurrentLocale, this, 98 false /* means any available languages from current spell checker */); 99 } 100 101 // Restore SpellCheckSpans in pool 102 for (int i = 0; i < mLength; i++) { 103 mSpellCheckSpans[i].setSpellCheckInProgress(false); 104 mIds[i] = -1; 105 } 106 mLength = 0; 107 108 // Remove existing misspelled SuggestionSpans 109 mTextView.removeMisspelledSpans((Editable) mTextView.getText()); 110 111 // This class is the listener for locale change: warn other locale-aware objects 112 mTextView.onLocaleChanged(); 113 } 114 115 private void setLocale(Locale locale) { 116 mCurrentLocale = locale; 117 118 resetSession(); 119 120 // Change SpellParsers' wordIterator locale 121 mWordIterator = new WordIterator(locale); 122 123 // This class is the listener for locale change: warn other locale-aware objects 124 mTextView.onLocaleChanged(); 125 } 126 127 /** 128 * @return true if a spell checker session has successfully been created. Returns false if not, 129 * for instance when spell checking has been disabled in settings. 130 */ 131 private boolean isSessionActive() { 132 return mSpellCheckerSession != null; 133 } 134 135 public void closeSession() { 136 if (mSpellCheckerSession != null) { 137 mSpellCheckerSession.close(); 138 } 139 140 final int length = mSpellParsers.length; 141 for (int i = 0; i < length; i++) { 142 mSpellParsers[i].finish(); 143 } 144 } 145 146 private int nextSpellCheckSpanIndex() { 147 for (int i = 0; i < mLength; i++) { 148 if (mIds[i] < 0) return i; 149 } 150 151 if (mLength == mSpellCheckSpans.length) { 152 final int newSize = mLength * 2; 153 int[] newIds = new int[newSize]; 154 SpellCheckSpan[] newSpellCheckSpans = new SpellCheckSpan[newSize]; 155 System.arraycopy(mIds, 0, newIds, 0, mLength); 156 System.arraycopy(mSpellCheckSpans, 0, newSpellCheckSpans, 0, mLength); 157 mIds = newIds; 158 mSpellCheckSpans = newSpellCheckSpans; 159 } 160 161 mSpellCheckSpans[mLength] = new SpellCheckSpan(); 162 mLength++; 163 return mLength - 1; 164 } 165 166 private void addSpellCheckSpan(Editable editable, int start, int end) { 167 final int index = nextSpellCheckSpanIndex(); 168 editable.setSpan(mSpellCheckSpans[index], start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 169 mIds[index] = mSpanSequenceCounter++; 170 } 171 172 public void removeSpellCheckSpan(SpellCheckSpan spellCheckSpan) { 173 for (int i = 0; i < mLength; i++) { 174 if (mSpellCheckSpans[i] == spellCheckSpan) { 175 mSpellCheckSpans[i].setSpellCheckInProgress(false); 176 mIds[i] = -1; 177 return; 178 } 179 } 180 } 181 182 public void onSelectionChanged() { 183 spellCheck(); 184 } 185 186 public void spellCheck(int start, int end) { 187 final Locale locale = mTextView.getTextServicesLocale(); 188 if (mCurrentLocale == null || (!(mCurrentLocale.equals(locale)))) { 189 setLocale(locale); 190 // Re-check the entire text 191 start = 0; 192 end = mTextView.getText().length(); 193 } else { 194 final boolean spellCheckerActivated = mTextServicesManager.isSpellCheckerEnabled(); 195 if (isSessionActive() != spellCheckerActivated) { 196 // Spell checker has been turned of or off since last spellCheck 197 resetSession(); 198 } 199 } 200 201 if (!isSessionActive()) return; 202 203 final int length = mSpellParsers.length; 204 for (int i = 0; i < length; i++) { 205 final SpellParser spellParser = mSpellParsers[i]; 206 if (spellParser.isFinished()) { 207 spellParser.init(start, end); 208 spellParser.parse(); 209 return; 210 } 211 } 212 213 // No available parser found in pool, create a new one 214 SpellParser[] newSpellParsers = new SpellParser[length + 1]; 215 System.arraycopy(mSpellParsers, 0, newSpellParsers, 0, length); 216 mSpellParsers = newSpellParsers; 217 218 SpellParser spellParser = new SpellParser(); 219 mSpellParsers[length] = spellParser; 220 spellParser.init(start, end); 221 spellParser.parse(); 222 } 223 224 private void spellCheck() { 225 if (mSpellCheckerSession == null) return; 226 227 Editable editable = (Editable) mTextView.getText(); 228 final int selectionStart = Selection.getSelectionStart(editable); 229 final int selectionEnd = Selection.getSelectionEnd(editable); 230 231 TextInfo[] textInfos = new TextInfo[mLength]; 232 int textInfosCount = 0; 233 234 for (int i = 0; i < mLength; i++) { 235 final SpellCheckSpan spellCheckSpan = mSpellCheckSpans[i]; 236 if (spellCheckSpan.isSpellCheckInProgress()) continue; 237 238 final int start = editable.getSpanStart(spellCheckSpan); 239 final int end = editable.getSpanEnd(spellCheckSpan); 240 241 // Do not check this word if the user is currently editing it 242 if (start >= 0 && end > start && (selectionEnd < start || selectionStart > end)) { 243 final String word = (editable instanceof SpannableStringBuilder) ? 244 ((SpannableStringBuilder) editable).substring(start, end) : 245 editable.subSequence(start, end).toString(); 246 spellCheckSpan.setSpellCheckInProgress(true); 247 textInfos[textInfosCount++] = new TextInfo(word, mCookie, mIds[i]); 248 } 249 } 250 251 if (textInfosCount > 0) { 252 if (textInfosCount < textInfos.length) { 253 TextInfo[] textInfosCopy = new TextInfo[textInfosCount]; 254 System.arraycopy(textInfos, 0, textInfosCopy, 0, textInfosCount); 255 textInfos = textInfosCopy; 256 } 257 mSpellCheckerSession.getSuggestions(textInfos, SuggestionSpan.SUGGESTIONS_MAX_SIZE, 258 false /* TODO Set sequentialWords to true for initial spell check */); 259 } 260 } 261 262 @Override 263 public void onGetSuggestions(SuggestionsInfo[] results) { 264 Editable editable = (Editable) mTextView.getText(); 265 266 for (int i = 0; i < results.length; i++) { 267 SuggestionsInfo suggestionsInfo = results[i]; 268 if (suggestionsInfo.getCookie() != mCookie) continue; 269 final int sequenceNumber = suggestionsInfo.getSequence(); 270 271 for (int j = 0; j < mLength; j++) { 272 if (sequenceNumber == mIds[j]) { 273 final int attributes = suggestionsInfo.getSuggestionsAttributes(); 274 boolean isInDictionary = 275 ((attributes & SuggestionsInfo.RESULT_ATTR_IN_THE_DICTIONARY) > 0); 276 boolean looksLikeTypo = 277 ((attributes & SuggestionsInfo.RESULT_ATTR_LOOKS_LIKE_TYPO) > 0); 278 279 SpellCheckSpan spellCheckSpan = mSpellCheckSpans[j]; 280 281 if (!isInDictionary && looksLikeTypo) { 282 createMisspelledSuggestionSpan(editable, suggestionsInfo, spellCheckSpan); 283 } 284 285 editable.removeSpan(spellCheckSpan); 286 break; 287 } 288 } 289 } 290 291 final int length = mSpellParsers.length; 292 for (int i = 0; i < length; i++) { 293 final SpellParser spellParser = mSpellParsers[i]; 294 if (!spellParser.isFinished()) { 295 spellParser.parse(); 296 } 297 } 298 } 299 300 private void createMisspelledSuggestionSpan(Editable editable, SuggestionsInfo suggestionsInfo, 301 SpellCheckSpan spellCheckSpan) { 302 final int start = editable.getSpanStart(spellCheckSpan); 303 final int end = editable.getSpanEnd(spellCheckSpan); 304 if (start < 0 || end <= start) return; // span was removed in the meantime 305 306 // Other suggestion spans may exist on that region, with identical suggestions, filter 307 // them out to avoid duplicates. 308 SuggestionSpan[] suggestionSpans = editable.getSpans(start, end, SuggestionSpan.class); 309 final int length = suggestionSpans.length; 310 for (int i = 0; i < length; i++) { 311 final int spanStart = editable.getSpanStart(suggestionSpans[i]); 312 final int spanEnd = editable.getSpanEnd(suggestionSpans[i]); 313 if (spanStart != start || spanEnd != end) { 314 // Nulled (to avoid new array allocation) if not on that exact same region 315 suggestionSpans[i] = null; 316 } 317 } 318 319 final int suggestionsCount = suggestionsInfo.getSuggestionsCount(); 320 String[] suggestions; 321 if (suggestionsCount <= 0) { 322 // A negative suggestion count is possible 323 suggestions = ArrayUtils.emptyArray(String.class); 324 } else { 325 int numberOfSuggestions = 0; 326 suggestions = new String[suggestionsCount]; 327 328 for (int i = 0; i < suggestionsCount; i++) { 329 final String spellSuggestion = suggestionsInfo.getSuggestionAt(i); 330 if (spellSuggestion == null) break; 331 boolean suggestionFound = false; 332 333 for (int j = 0; j < length && !suggestionFound; j++) { 334 if (suggestionSpans[j] == null) break; 335 336 String[] suggests = suggestionSpans[j].getSuggestions(); 337 for (int k = 0; k < suggests.length; k++) { 338 if (spellSuggestion.equals(suggests[k])) { 339 // The suggestion is already provided by an other SuggestionSpan 340 suggestionFound = true; 341 break; 342 } 343 } 344 } 345 346 if (!suggestionFound) { 347 suggestions[numberOfSuggestions++] = spellSuggestion; 348 } 349 } 350 351 if (numberOfSuggestions != suggestionsCount) { 352 String[] newSuggestions = new String[numberOfSuggestions]; 353 System.arraycopy(suggestions, 0, newSuggestions, 0, numberOfSuggestions); 354 suggestions = newSuggestions; 355 } 356 } 357 358 SuggestionSpan suggestionSpan = new SuggestionSpan(mTextView.getContext(), suggestions, 359 SuggestionSpan.FLAG_EASY_CORRECT | SuggestionSpan.FLAG_MISSPELLED); 360 editable.setSpan(suggestionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 361 362 mTextView.invalidateRegion(start, end); 363 } 364 365 private class SpellParser { 366 private Object mRange = new Object(); 367 368 public void init(int start, int end) { 369 ((Editable) mTextView.getText()).setSpan(mRange, start, end, 370 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 371 } 372 373 public void finish() { 374 ((Editable) mTextView.getText()).removeSpan(mRange); 375 } 376 377 public boolean isFinished() { 378 return ((Editable) mTextView.getText()).getSpanStart(mRange) < 0; 379 } 380 381 public void parse() { 382 Editable editable = (Editable) mTextView.getText(); 383 // Iterate over the newly added text and schedule new SpellCheckSpans 384 final int start = editable.getSpanStart(mRange); 385 final int end = editable.getSpanEnd(mRange); 386 mWordIterator.setCharSequence(editable, start, end); 387 388 // Move back to the beginning of the current word, if any 389 int wordStart = mWordIterator.preceding(start); 390 int wordEnd; 391 if (wordStart == BreakIterator.DONE) { 392 wordEnd = mWordIterator.following(start); 393 if (wordEnd != BreakIterator.DONE) { 394 wordStart = mWordIterator.getBeginning(wordEnd); 395 } 396 } else { 397 wordEnd = mWordIterator.getEnd(wordStart); 398 } 399 if (wordEnd == BreakIterator.DONE) { 400 editable.removeSpan(mRange); 401 return; 402 } 403 404 // We need to expand by one character because we want to include the spans that 405 // end/start at position start/end respectively. 406 SpellCheckSpan[] spellCheckSpans = editable.getSpans(start - 1, end + 1, 407 SpellCheckSpan.class); 408 SuggestionSpan[] suggestionSpans = editable.getSpans(start - 1, end + 1, 409 SuggestionSpan.class); 410 411 int nbWordsChecked = 0; 412 boolean scheduleOtherSpellCheck = false; 413 414 while (wordStart <= end) { 415 if (wordEnd >= start && wordEnd > wordStart) { 416 // A new word has been created across the interval boundaries with this edit. 417 // Previous spans (ended on start / started on end) removed, not valid anymore 418 if (wordStart < start && wordEnd > start) { 419 removeSpansAt(editable, start, spellCheckSpans); 420 removeSpansAt(editable, start, suggestionSpans); 421 } 422 423 if (wordStart < end && wordEnd > end) { 424 removeSpansAt(editable, end, spellCheckSpans); 425 removeSpansAt(editable, end, suggestionSpans); 426 } 427 428 // Do not create new boundary spans if they already exist 429 boolean createSpellCheckSpan = true; 430 if (wordEnd == start) { 431 for (int i = 0; i < spellCheckSpans.length; i++) { 432 final int spanEnd = editable.getSpanEnd(spellCheckSpans[i]); 433 if (spanEnd == start) { 434 createSpellCheckSpan = false; 435 break; 436 } 437 } 438 } 439 440 if (wordStart == end) { 441 for (int i = 0; i < spellCheckSpans.length; i++) { 442 final int spanStart = editable.getSpanStart(spellCheckSpans[i]); 443 if (spanStart == end) { 444 createSpellCheckSpan = false; 445 break; 446 } 447 } 448 } 449 450 if (createSpellCheckSpan) { 451 if (nbWordsChecked == MAX_SPELL_BATCH_SIZE) { 452 scheduleOtherSpellCheck = true; 453 break; 454 } 455 addSpellCheckSpan(editable, wordStart, wordEnd); 456 nbWordsChecked++; 457 } 458 } 459 460 // iterate word by word 461 wordEnd = mWordIterator.following(wordEnd); 462 if (wordEnd == BreakIterator.DONE) break; 463 wordStart = mWordIterator.getBeginning(wordEnd); 464 if (wordStart == BreakIterator.DONE) { 465 break; 466 } 467 } 468 469 if (scheduleOtherSpellCheck) { 470 editable.setSpan(mRange, wordStart, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 471 } else { 472 editable.removeSpan(mRange); 473 } 474 475 spellCheck(); 476 } 477 478 private <T> void removeSpansAt(Editable editable, int offset, T[] spans) { 479 final int length = spans.length; 480 for (int i = 0; i < length; i++) { 481 final T span = spans[i]; 482 final int start = editable.getSpanStart(span); 483 if (start > offset) continue; 484 final int end = editable.getSpanEnd(span); 485 if (end < offset) continue; 486 editable.removeSpan(span); 487 } 488 } 489 } 490} 491