RecipientEditTextView.java revision f9171bb324685aef7d274cd43427512a56f22522
1/* 2 3 * Copyright (C) 2011 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.ex.chips; 19 20import java.util.ArrayList; 21import java.util.Arrays; 22import java.util.Collection; 23import java.util.Collections; 24import java.util.Comparator; 25import java.util.HashSet; 26import java.util.List; 27import java.util.Map; 28import java.util.Set; 29import java.util.regex.Matcher; 30import java.util.regex.Pattern; 31 32import android.app.Dialog; 33import android.content.ClipData; 34import android.content.ClipDescription; 35import android.content.ClipboardManager; 36import android.content.Context; 37import android.content.DialogInterface; 38import android.content.DialogInterface.OnDismissListener; 39import android.content.res.Resources; 40import android.content.res.TypedArray; 41import android.graphics.Bitmap; 42import android.graphics.BitmapFactory; 43import android.graphics.Canvas; 44import android.graphics.Matrix; 45import android.graphics.Point; 46import android.graphics.Rect; 47import android.graphics.RectF; 48import android.graphics.drawable.BitmapDrawable; 49import android.graphics.drawable.Drawable; 50import android.os.AsyncTask; 51import android.os.Handler; 52import android.os.Looper; 53import android.os.Message; 54import android.os.Parcelable; 55import android.text.Editable; 56import android.text.InputType; 57import android.text.Layout; 58import android.text.Spannable; 59import android.text.SpannableString; 60import android.text.Spanned; 61import android.text.TextPaint; 62import android.text.TextUtils; 63import android.text.TextWatcher; 64import android.text.method.QwertyKeyListener; 65import android.text.style.ImageSpan; 66import android.text.util.Rfc822Token; 67import android.text.util.Rfc822Tokenizer; 68import android.util.AttributeSet; 69import android.util.Log; 70import android.util.TypedValue; 71import android.view.ActionMode; 72import android.view.ActionMode.Callback; 73import android.view.DragEvent; 74import android.view.GestureDetector; 75import android.view.KeyEvent; 76import android.view.LayoutInflater; 77import android.view.Menu; 78import android.view.MenuItem; 79import android.view.MotionEvent; 80import android.view.View; 81import android.view.View.OnClickListener; 82import android.view.ViewParent; 83import android.view.inputmethod.EditorInfo; 84import android.view.inputmethod.InputConnection; 85import android.widget.AdapterView; 86import android.widget.AdapterView.OnItemClickListener; 87import android.widget.Button; 88import android.widget.Filterable; 89import android.widget.ListAdapter; 90import android.widget.ListPopupWindow; 91import android.widget.ListView; 92import android.widget.MultiAutoCompleteTextView; 93import android.widget.ScrollView; 94import android.widget.TextView; 95 96import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; 97import com.android.ex.chips.recipientchip.DrawableRecipientChip; 98import com.android.ex.chips.recipientchip.InvisibleRecipientChip; 99import com.android.ex.chips.recipientchip.VisibleRecipientChip; 100 101/** 102 * RecipientEditTextView is an auto complete text view for use with applications 103 * that use the new Chips UI for addressing a message to recipients. 104 */ 105public class RecipientEditTextView extends MultiAutoCompleteTextView implements 106 OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener, 107 GestureDetector.OnGestureListener, OnDismissListener, OnClickListener, 108 TextView.OnEditorActionListener { 109 110 private static final char COMMIT_CHAR_COMMA = ','; 111 112 private static final char COMMIT_CHAR_SEMICOLON = ';'; 113 114 private static final char COMMIT_CHAR_SPACE = ' '; 115 116 private static final String SEPARATOR = String.valueOf(COMMIT_CHAR_COMMA) 117 + String.valueOf(COMMIT_CHAR_SPACE); 118 119 private static final String TAG = "RecipientEditTextView"; 120 121 private static int DISMISS = "dismiss".hashCode(); 122 123 private static final long DISMISS_DELAY = 300; 124 125 // TODO: get correct number/ algorithm from with UX. 126 // Visible for testing. 127 /*package*/ static final int CHIP_LIMIT = 2; 128 129 private static final int MAX_CHIPS_PARSED = 50; 130 131 private static int sSelectedTextColor = -1; 132 133 // Resources for displaying chips. 134 private Drawable mChipBackground = null; 135 136 private Drawable mChipDelete = null; 137 138 private Drawable mInvalidChipBackground; 139 140 private Drawable mChipBackgroundPressed; 141 142 private float mChipHeight; 143 144 private float mChipFontSize; 145 146 private float mLineSpacingExtra; 147 148 private int mChipPadding; 149 150 private Tokenizer mTokenizer; 151 152 private Validator mValidator; 153 154 private DrawableRecipientChip mSelectedChip; 155 156 private int mAlternatesLayout; 157 158 private Bitmap mDefaultContactPhoto; 159 160 private ImageSpan mMoreChip; 161 162 private TextView mMoreItem; 163 164 // VisibleForTesting 165 final ArrayList<String> mPendingChips = new ArrayList<String>(); 166 167 private Handler mHandler; 168 169 private int mPendingChipsCount = 0; 170 171 private boolean mNoChips = false; 172 173 private ListPopupWindow mAlternatesPopup; 174 175 private ListPopupWindow mAddressPopup; 176 177 // VisibleForTesting 178 ArrayList<DrawableRecipientChip> mTemporaryRecipients; 179 180 private ArrayList<DrawableRecipientChip> mRemovedSpans; 181 182 private boolean mShouldShrink = true; 183 184 // Chip copy fields. 185 private GestureDetector mGestureDetector; 186 187 private Dialog mCopyDialog; 188 189 private String mCopyAddress; 190 191 /** 192 * Used with {@link #mAlternatesPopup}. Handles clicks to alternate addresses for a 193 * selected chip. 194 */ 195 private OnItemClickListener mAlternatesListener; 196 197 private int mCheckedItem; 198 199 private TextWatcher mTextWatcher; 200 201 // Obtain the enclosing scroll view, if it exists, so that the view can be 202 // scrolled to show the last line of chips content. 203 private ScrollView mScrollView; 204 205 private boolean mTriedGettingScrollView; 206 207 private boolean mDragEnabled = false; 208 209 // This pattern comes from android.util.Patterns. It has been tweaked to handle a "1" before 210 // parens, so numbers such as "1 (425) 222-2342" match. 211 private static final Pattern PHONE_PATTERN 212 = Pattern.compile( // sdd = space, dot, or dash 213 "(\\+[0-9]+[\\- \\.]*)?" // +<digits><sdd>* 214 + "(1?[ ]*\\([0-9]+\\)[\\- \\.]*)?" // 1(<digits>)<sdd>* 215 + "([0-9][0-9\\- \\.][0-9\\- \\.]+[0-9])"); // <digit><digit|sdd>+<digit> 216 217 private final Runnable mAddTextWatcher = new Runnable() { 218 @Override 219 public void run() { 220 if (mTextWatcher == null) { 221 mTextWatcher = new RecipientTextWatcher(); 222 addTextChangedListener(mTextWatcher); 223 } 224 } 225 }; 226 227 private IndividualReplacementTask mIndividualReplacements; 228 229 private Runnable mHandlePendingChips = new Runnable() { 230 231 @Override 232 public void run() { 233 handlePendingChips(); 234 } 235 236 }; 237 238 private Runnable mDelayedShrink = new Runnable() { 239 240 @Override 241 public void run() { 242 shrink(); 243 } 244 245 }; 246 247 private int mMaxLines; 248 249 private static int sExcessTopPadding = -1; 250 251 private int mActionBarHeight; 252 253 public RecipientEditTextView(Context context, AttributeSet attrs) { 254 super(context, attrs); 255 setChipDimensions(context, attrs); 256 if (sSelectedTextColor == -1) { 257 sSelectedTextColor = context.getResources().getColor(android.R.color.white); 258 } 259 mAlternatesPopup = new ListPopupWindow(context); 260 mAddressPopup = new ListPopupWindow(context); 261 mCopyDialog = new Dialog(context); 262 mAlternatesListener = new OnItemClickListener() { 263 @Override 264 public void onItemClick(AdapterView<?> adapterView,View view, int position, 265 long rowId) { 266 mAlternatesPopup.setOnItemClickListener(null); 267 replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter()) 268 .getRecipientEntry(position)); 269 Message delayed = Message.obtain(mHandler, DISMISS); 270 delayed.obj = mAlternatesPopup; 271 mHandler.sendMessageDelayed(delayed, DISMISS_DELAY); 272 clearComposingText(); 273 } 274 }; 275 setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); 276 setOnItemClickListener(this); 277 setCustomSelectionActionModeCallback(this); 278 mHandler = new Handler() { 279 @Override 280 public void handleMessage(Message msg) { 281 if (msg.what == DISMISS) { 282 ((ListPopupWindow) msg.obj).dismiss(); 283 return; 284 } 285 super.handleMessage(msg); 286 } 287 }; 288 mTextWatcher = new RecipientTextWatcher(); 289 addTextChangedListener(mTextWatcher); 290 mGestureDetector = new GestureDetector(context, this); 291 setOnEditorActionListener(this); 292 } 293 294 @Override 295 public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { 296 if (action == EditorInfo.IME_ACTION_DONE) { 297 if (commitDefault()) { 298 return true; 299 } 300 if (mSelectedChip != null) { 301 clearSelectedChip(); 302 return true; 303 } else if (focusNext()) { 304 return true; 305 } 306 } 307 return false; 308 } 309 310 @Override 311 public InputConnection onCreateInputConnection(EditorInfo outAttrs) { 312 InputConnection connection = super.onCreateInputConnection(outAttrs); 313 int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION; 314 if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) { 315 // clear the existing action 316 outAttrs.imeOptions ^= imeActions; 317 // set the DONE action 318 outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE; 319 } 320 if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { 321 outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; 322 } 323 324 outAttrs.actionId = EditorInfo.IME_ACTION_DONE; 325 outAttrs.actionLabel = getContext().getString(R.string.done); 326 return connection; 327 } 328 329 /*package*/ DrawableRecipientChip getLastChip() { 330 DrawableRecipientChip last = null; 331 DrawableRecipientChip[] chips = getSortedRecipients(); 332 if (chips != null && chips.length > 0) { 333 last = chips[chips.length - 1]; 334 } 335 return last; 336 } 337 338 @Override 339 public void onSelectionChanged(int start, int end) { 340 // When selection changes, see if it is inside the chips area. 341 // If so, move the cursor back after the chips again. 342 DrawableRecipientChip last = getLastChip(); 343 if (last != null && start < getSpannable().getSpanEnd(last)) { 344 // Grab the last chip and set the cursor to after it. 345 setSelection(Math.min(getSpannable().getSpanEnd(last) + 1, getText().length())); 346 } 347 super.onSelectionChanged(start, end); 348 } 349 350 @Override 351 public void onRestoreInstanceState(Parcelable state) { 352 if (!TextUtils.isEmpty(getText())) { 353 super.onRestoreInstanceState(null); 354 } else { 355 super.onRestoreInstanceState(state); 356 } 357 } 358 359 @Override 360 public Parcelable onSaveInstanceState() { 361 // If the user changes orientation while they are editing, just roll back the selection. 362 clearSelectedChip(); 363 return super.onSaveInstanceState(); 364 } 365 366 /** 367 * Convenience method: Append the specified text slice to the TextView's 368 * display buffer, upgrading it to BufferType.EDITABLE if it was 369 * not already editable. Commas are excluded as they are added automatically 370 * by the view. 371 */ 372 @Override 373 public void append(CharSequence text, int start, int end) { 374 // We don't care about watching text changes while appending. 375 if (mTextWatcher != null) { 376 removeTextChangedListener(mTextWatcher); 377 } 378 super.append(text, start, end); 379 if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) { 380 String displayString = text.toString(); 381 382 if (!displayString.trim().endsWith(String.valueOf(COMMIT_CHAR_COMMA))) { 383 // We have no separator, so we should add it 384 super.append(SEPARATOR, 0, SEPARATOR.length()); 385 displayString += SEPARATOR; 386 } 387 388 if (!TextUtils.isEmpty(displayString) 389 && TextUtils.getTrimmedLength(displayString) > 0) { 390 mPendingChipsCount++; 391 mPendingChips.add(displayString); 392 } 393 } 394 // Put a message on the queue to make sure we ALWAYS handle pending 395 // chips. 396 if (mPendingChipsCount > 0) { 397 postHandlePendingChips(); 398 } 399 mHandler.post(mAddTextWatcher); 400 } 401 402 @Override 403 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 404 super.onFocusChanged(hasFocus, direction, previous); 405 if (!hasFocus) { 406 shrink(); 407 } else { 408 expand(); 409 } 410 } 411 412 @Override 413 public void performValidation() { 414 // Do nothing. Chips handles its own validation. 415 } 416 417 private void shrink() { 418 if (mTokenizer == null) { 419 return; 420 } 421 long contactId = mSelectedChip != null ? mSelectedChip.getEntry().getContactId() : -1; 422 if (mSelectedChip != null && contactId != RecipientEntry.INVALID_CONTACT 423 && (!isPhoneQuery() && contactId != RecipientEntry.GENERATED_CONTACT)) { 424 clearSelectedChip(); 425 } else { 426 if (getWidth() <= 0) { 427 // We don't have the width yet which means the view hasn't been drawn yet 428 // and there is no reason to attempt to commit chips yet. 429 // This focus lost must be the result of an orientation change 430 // or an initial rendering. 431 // Re-post the shrink for later. 432 mHandler.removeCallbacks(mDelayedShrink); 433 mHandler.post(mDelayedShrink); 434 return; 435 } 436 // Reset any pending chips as they would have been handled 437 // when the field lost focus. 438 if (mPendingChipsCount > 0) { 439 postHandlePendingChips(); 440 } else { 441 Editable editable = getText(); 442 int end = getSelectionEnd(); 443 int start = mTokenizer.findTokenStart(editable, end); 444 DrawableRecipientChip[] chips = 445 getSpannable().getSpans(start, end, DrawableRecipientChip.class); 446 if ((chips == null || chips.length == 0)) { 447 Editable text = getText(); 448 int whatEnd = mTokenizer.findTokenEnd(text, start); 449 // This token was already tokenized, so skip past the ending token. 450 if (whatEnd < text.length() && text.charAt(whatEnd) == ',') { 451 whatEnd = movePastTerminators(whatEnd); 452 } 453 // In the middle of chip; treat this as an edit 454 // and commit the whole token. 455 int selEnd = getSelectionEnd(); 456 if (whatEnd != selEnd) { 457 handleEdit(start, whatEnd); 458 } else { 459 commitChip(start, end, editable); 460 } 461 } 462 } 463 mHandler.post(mAddTextWatcher); 464 } 465 createMoreChip(); 466 } 467 468 private void expand() { 469 if (mShouldShrink) { 470 setMaxLines(Integer.MAX_VALUE); 471 } 472 removeMoreChip(); 473 setCursorVisible(true); 474 Editable text = getText(); 475 setSelection(text != null && text.length() > 0 ? text.length() : 0); 476 // If there are any temporary chips, try replacing them now that the user 477 // has expanded the field. 478 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) { 479 new RecipientReplacementTask().execute(); 480 mTemporaryRecipients = null; 481 } 482 } 483 484 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 485 paint.setTextSize(mChipFontSize); 486 if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) { 487 Log.d(TAG, "Max width is negative: " + maxWidth); 488 } 489 return TextUtils.ellipsize(text, paint, maxWidth, 490 TextUtils.TruncateAt.END); 491 } 492 493 private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint) { 494 // Ellipsize the text so that it takes AT MOST the entire width of the 495 // autocomplete text entry area. Make sure to leave space for padding 496 // on the sides. 497 int height = (int) mChipHeight; 498 int deleteWidth = height; 499 float[] widths = new float[1]; 500 paint.getTextWidths(" ", widths); 501 CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint, 502 calculateAvailableWidth() - deleteWidth - widths[0]); 503 504 // Make sure there is a minimum chip width so the user can ALWAYS 505 // tap a chip without difficulty. 506 int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 507 ellipsizedText.length())) 508 + (mChipPadding * 2) + deleteWidth); 509 510 // Create the background of the chip. 511 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 512 Canvas canvas = new Canvas(tmpBitmap); 513 if (mChipBackgroundPressed != null) { 514 mChipBackgroundPressed.setBounds(0, 0, width, height); 515 mChipBackgroundPressed.draw(canvas); 516 paint.setColor(sSelectedTextColor); 517 // Vertically center the text in the chip. 518 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, 519 getTextYOffset((String) ellipsizedText, paint, height), paint); 520 // Make the delete a square. 521 Rect backgroundPadding = new Rect(); 522 mChipBackgroundPressed.getPadding(backgroundPadding); 523 mChipDelete.setBounds(width - deleteWidth + backgroundPadding.left, 524 0 + backgroundPadding.top, 525 width - backgroundPadding.right, 526 height - backgroundPadding.bottom); 527 mChipDelete.draw(canvas); 528 } else { 529 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 530 } 531 return tmpBitmap; 532 } 533 534 535 private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, 536 boolean leaveBlankIconSpacer) { 537 // Ellipsize the text so that it takes AT MOST the entire width of the 538 // autocomplete text entry area. Make sure to leave space for padding 539 // on the sides. 540 int height = (int) mChipHeight; 541 int iconWidth = height; 542 float[] widths = new float[1]; 543 paint.getTextWidths(" ", widths); 544 CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint, 545 calculateAvailableWidth() - iconWidth - widths[0]); 546 // Make sure there is a minimum chip width so the user can ALWAYS 547 // tap a chip without difficulty. 548 int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 549 ellipsizedText.length())) 550 + (mChipPadding * 2) + iconWidth); 551 552 // Create the background of the chip. 553 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 554 Canvas canvas = new Canvas(tmpBitmap); 555 Drawable background = getChipBackground(contact); 556 if (background != null) { 557 background.setBounds(0, 0, width, height); 558 background.draw(canvas); 559 560 // Don't draw photos for recipients that have been typed in OR generated on the fly. 561 long contactId = contact.getContactId(); 562 boolean drawPhotos = isPhoneQuery() ? 563 contactId != RecipientEntry.INVALID_CONTACT 564 : (contactId != RecipientEntry.INVALID_CONTACT 565 && (contactId != RecipientEntry.GENERATED_CONTACT && 566 !TextUtils.isEmpty(contact.getDisplayName()))); 567 if (drawPhotos) { 568 byte[] photoBytes = contact.getPhotoBytes(); 569 // There may not be a photo yet if anything but the first contact address 570 // was selected. 571 if (photoBytes == null && contact.getPhotoThumbnailUri() != null) { 572 // TODO: cache this in the recipient entry? 573 ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact 574 .getPhotoThumbnailUri()); 575 photoBytes = contact.getPhotoBytes(); 576 } 577 578 Bitmap photo; 579 if (photoBytes != null) { 580 photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length); 581 } else { 582 // TODO: can the scaled down default photo be cached? 583 photo = mDefaultContactPhoto; 584 } 585 // Draw the photo on the left side. 586 if (photo != null) { 587 RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight()); 588 Rect backgroundPadding = new Rect(); 589 mChipBackground.getPadding(backgroundPadding); 590 RectF dst = new RectF(width - iconWidth + backgroundPadding.left, 591 0 + backgroundPadding.top, 592 width - backgroundPadding.right, 593 height - backgroundPadding.bottom); 594 Matrix matrix = new Matrix(); 595 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); 596 canvas.drawBitmap(photo, matrix, paint); 597 } 598 } else if (!leaveBlankIconSpacer || isPhoneQuery()) { 599 iconWidth = 0; 600 } 601 paint.setColor(getContext().getResources().getColor(android.R.color.black)); 602 // Vertically center the text in the chip. 603 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, 604 getTextYOffset((String)ellipsizedText, paint, height), paint); 605 } else { 606 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 607 } 608 return tmpBitmap; 609 } 610 611 /** 612 * Get the background drawable for a RecipientChip. 613 */ 614 // Visible for testing. 615 /* package */Drawable getChipBackground(RecipientEntry contact) { 616 return contact.isValid() ? mChipBackground : mInvalidChipBackground; 617 } 618 619 private static float getTextYOffset(String text, TextPaint paint, int height) { 620 Rect bounds = new Rect(); 621 paint.getTextBounds(text, 0, text.length(), bounds); 622 int textHeight = bounds.bottom - bounds.top ; 623 return height - ((height - textHeight) / 2) - (int)paint.descent(); 624 } 625 626 private DrawableRecipientChip constructChipSpan(RecipientEntry contact, boolean pressed, 627 boolean leaveIconSpace) throws NullPointerException { 628 if (mChipBackground == null) { 629 throw new NullPointerException( 630 "Unable to render any chips as setChipDimensions was not called."); 631 } 632 633 TextPaint paint = getPaint(); 634 float defaultSize = paint.getTextSize(); 635 int defaultColor = paint.getColor(); 636 637 Bitmap tmpBitmap; 638 if (pressed) { 639 tmpBitmap = createSelectedChip(contact, paint); 640 641 } else { 642 tmpBitmap = createUnselectedChip(contact, paint, leaveIconSpace); 643 } 644 645 // Pass the full text, un-ellipsized, to the chip. 646 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 647 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 648 DrawableRecipientChip recipientChip = new VisibleRecipientChip(result, contact); 649 // Return text to the original size. 650 paint.setTextSize(defaultSize); 651 paint.setColor(defaultColor); 652 return recipientChip; 653 } 654 655 /** 656 * Calculate the bottom of the line the chip will be located on using: 657 * 1) which line the chip appears on 658 * 2) the height of a chip 659 * 3) padding built into the edit text view 660 */ 661 private int calculateOffsetFromBottom(int line) { 662 // Line offsets start at zero. 663 int actualLine = getLineCount() - (line + 1); 664 return -((actualLine * ((int) mChipHeight) + getPaddingBottom()) + getPaddingTop()) 665 + getDropDownVerticalOffset(); 666 } 667 668 /** 669 * Get the max amount of space a chip can take up. The formula takes into 670 * account the width of the EditTextView, any view padding, and padding 671 * that will be added to the chip. 672 */ 673 private float calculateAvailableWidth() { 674 return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2); 675 } 676 677 678 private void setChipDimensions(Context context, AttributeSet attrs) { 679 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0, 680 0); 681 Resources r = getContext().getResources(); 682 683 mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground); 684 if (mChipBackground == null) { 685 mChipBackground = r.getDrawable(R.drawable.chip_background); 686 } 687 mChipBackgroundPressed = a 688 .getDrawable(R.styleable.RecipientEditTextView_chipBackgroundPressed); 689 if (mChipBackgroundPressed == null) { 690 mChipBackgroundPressed = r.getDrawable(R.drawable.chip_background_selected); 691 } 692 mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete); 693 if (mChipDelete == null) { 694 mChipDelete = r.getDrawable(R.drawable.chip_delete); 695 } 696 mChipPadding = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1); 697 if (mChipPadding == -1) { 698 mChipPadding = (int) r.getDimension(R.dimen.chip_padding); 699 } 700 mAlternatesLayout = a.getResourceId(R.styleable.RecipientEditTextView_chipAlternatesLayout, 701 -1); 702 if (mAlternatesLayout == -1) { 703 mAlternatesLayout = R.layout.chips_alternate_item; 704 } 705 706 mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture); 707 708 mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null); 709 710 mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1); 711 if (mChipHeight == -1) { 712 mChipHeight = r.getDimension(R.dimen.chip_height); 713 } 714 mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1); 715 if (mChipFontSize == -1) { 716 mChipFontSize = r.getDimension(R.dimen.chip_text_size); 717 } 718 mInvalidChipBackground = a 719 .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground); 720 if (mInvalidChipBackground == null) { 721 mInvalidChipBackground = r.getDrawable(R.drawable.chip_background_invalid); 722 } 723 mLineSpacingExtra = r.getDimension(R.dimen.line_spacing_extra); 724 mMaxLines = r.getInteger(R.integer.chips_max_lines); 725 TypedValue tv = new TypedValue(); 726 if (context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { 727 mActionBarHeight = TypedValue.complexToDimensionPixelSize(tv.data, getResources() 728 .getDisplayMetrics()); 729 } 730 a.recycle(); 731 } 732 733 // Visible for testing. 734 /* package */ void setMoreItem(TextView moreItem) { 735 mMoreItem = moreItem; 736 } 737 738 739 // Visible for testing. 740 /* package */ void setChipBackground(Drawable chipBackground) { 741 mChipBackground = chipBackground; 742 } 743 744 // Visible for testing. 745 /* package */ void setChipHeight(int height) { 746 mChipHeight = height; 747 } 748 749 /** 750 * Set whether to shrink the recipients field such that at most 751 * one line of recipients chips are shown when the field loses 752 * focus. By default, the number of displayed recipients will be 753 * limited and a "more" chip will be shown when focus is lost. 754 * @param shrink 755 */ 756 public void setOnFocusListShrinkRecipients(boolean shrink) { 757 mShouldShrink = shrink; 758 } 759 760 @Override 761 public void onSizeChanged(int width, int height, int oldw, int oldh) { 762 super.onSizeChanged(width, height, oldw, oldh); 763 if (width != 0 && height != 0) { 764 if (mPendingChipsCount > 0) { 765 postHandlePendingChips(); 766 } else { 767 checkChipWidths(); 768 } 769 } 770 // Try to find the scroll view parent, if it exists. 771 if (mScrollView == null && !mTriedGettingScrollView) { 772 ViewParent parent = getParent(); 773 while (parent != null && !(parent instanceof ScrollView)) { 774 parent = parent.getParent(); 775 } 776 if (parent != null) { 777 mScrollView = (ScrollView) parent; 778 } 779 mTriedGettingScrollView = true; 780 } 781 } 782 783 private void postHandlePendingChips() { 784 mHandler.removeCallbacks(mHandlePendingChips); 785 mHandler.post(mHandlePendingChips); 786 } 787 788 private void checkChipWidths() { 789 // Check the widths of the associated chips. 790 DrawableRecipientChip[] chips = getSortedRecipients(); 791 if (chips != null) { 792 Rect bounds; 793 for (DrawableRecipientChip chip : chips) { 794 bounds = chip.getBounds(); 795 if (getWidth() > 0 && bounds.right - bounds.left > getWidth()) { 796 // Need to redraw that chip. 797 replaceChip(chip, chip.getEntry()); 798 } 799 } 800 } 801 } 802 803 // Visible for testing. 804 /*package*/ void handlePendingChips() { 805 if (getViewWidth() <= 0) { 806 // The widget has not been sized yet. 807 // This will be called as a result of onSizeChanged 808 // at a later point. 809 return; 810 } 811 if (mPendingChipsCount <= 0) { 812 return; 813 } 814 815 synchronized (mPendingChips) { 816 Editable editable = getText(); 817 // Tokenize! 818 if (mPendingChipsCount <= MAX_CHIPS_PARSED) { 819 for (int i = 0; i < mPendingChips.size(); i++) { 820 String current = mPendingChips.get(i); 821 int tokenStart = editable.toString().indexOf(current); 822 // Always leave a space at the end between tokens. 823 int tokenEnd = tokenStart + current.length() - 1; 824 if (tokenStart >= 0) { 825 // When we have a valid token, include it with the token 826 // to the left. 827 if (tokenEnd < editable.length() - 2 828 && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) { 829 tokenEnd++; 830 } 831 createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT 832 || !mShouldShrink); 833 } 834 mPendingChipsCount--; 835 } 836 sanitizeEnd(); 837 } else { 838 mNoChips = true; 839 } 840 841 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0 842 && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) { 843 if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) { 844 new RecipientReplacementTask().execute(); 845 mTemporaryRecipients = null; 846 } else { 847 // Create the "more" chip 848 mIndividualReplacements = new IndividualReplacementTask(); 849 mIndividualReplacements.execute(new ArrayList<DrawableRecipientChip>( 850 mTemporaryRecipients.subList(0, CHIP_LIMIT))); 851 if (mTemporaryRecipients.size() > CHIP_LIMIT) { 852 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>( 853 mTemporaryRecipients.subList(CHIP_LIMIT, 854 mTemporaryRecipients.size())); 855 } else { 856 mTemporaryRecipients = null; 857 } 858 createMoreChip(); 859 } 860 } else { 861 // There are too many recipients to look up, so just fall back 862 // to showing addresses for all of them. 863 mTemporaryRecipients = null; 864 createMoreChip(); 865 } 866 mPendingChipsCount = 0; 867 mPendingChips.clear(); 868 } 869 } 870 871 // Visible for testing. 872 /*package*/ int getViewWidth() { 873 return getWidth(); 874 } 875 876 /** 877 * Remove any characters after the last valid chip. 878 */ 879 // Visible for testing. 880 /*package*/ void sanitizeEnd() { 881 // Don't sanitize while we are waiting for pending chips to complete. 882 if (mPendingChipsCount > 0) { 883 return; 884 } 885 // Find the last chip; eliminate any commit characters after it. 886 DrawableRecipientChip[] chips = getSortedRecipients(); 887 Spannable spannable = getSpannable(); 888 if (chips != null && chips.length > 0) { 889 int end; 890 mMoreChip = getMoreChip(); 891 if (mMoreChip != null) { 892 end = spannable.getSpanEnd(mMoreChip); 893 } else { 894 end = getSpannable().getSpanEnd(getLastChip()); 895 } 896 Editable editable = getText(); 897 int length = editable.length(); 898 if (length > end) { 899 // See what characters occur after that and eliminate them. 900 if (Log.isLoggable(TAG, Log.DEBUG)) { 901 Log.d(TAG, "There were extra characters after the last tokenizable entry." 902 + editable); 903 } 904 editable.delete(end + 1, length); 905 } 906 } 907 } 908 909 /** 910 * Create a chip that represents just the email address of a recipient. At some later 911 * point, this chip will be attached to a real contact entry, if one exists. 912 */ 913 // VisibleForTesting 914 void createReplacementChip(int tokenStart, int tokenEnd, Editable editable, 915 boolean visible) { 916 if (alreadyHasChip(tokenStart, tokenEnd)) { 917 // There is already a chip present at this location. 918 // Don't recreate it. 919 return; 920 } 921 String token = editable.toString().substring(tokenStart, tokenEnd); 922 final String trimmedToken = token.trim(); 923 int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA); 924 if (commitCharIndex == trimmedToken.length() - 1) { 925 token = trimmedToken.substring(0, trimmedToken.length() - 1); 926 } 927 RecipientEntry entry = createTokenizedEntry(token); 928 if (entry != null) { 929 DrawableRecipientChip chip = null; 930 try { 931 if (!mNoChips) { 932 /* 933 * leave space for the contact icon if this is not just an 934 * email address 935 */ 936 boolean leaveSpace = TextUtils.isEmpty(entry.getDisplayName()) 937 || TextUtils.equals(entry.getDisplayName(), 938 entry.getDestination()); 939 chip = visible ? 940 constructChipSpan(entry, false, leaveSpace) 941 : new InvisibleRecipientChip(entry); 942 } 943 } catch (NullPointerException e) { 944 Log.e(TAG, e.getMessage(), e); 945 } 946 editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 947 // Add this chip to the list of entries "to replace" 948 if (chip != null) { 949 if (mTemporaryRecipients == null) { 950 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>(); 951 } 952 chip.setOriginalText(token); 953 mTemporaryRecipients.add(chip); 954 } 955 } 956 } 957 958 private static boolean isPhoneNumber(String number) { 959 // TODO: replace this function with libphonenumber's isPossibleNumber (see 960 // PhoneNumberUtil). One complication is that it requires the sender's region which 961 // comes from the CurrentCountryIso. For now, let's just do this simple match. 962 if (TextUtils.isEmpty(number)) { 963 return false; 964 } 965 966 Matcher match = PHONE_PATTERN.matcher(number); 967 return match.matches(); 968 } 969 970 private RecipientEntry createTokenizedEntry(String token) { 971 if (TextUtils.isEmpty(token)) { 972 return null; 973 } 974 if (isPhoneQuery() && isPhoneNumber(token)) { 975 return RecipientEntry.constructFakePhoneEntry(token, true); 976 } 977 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token); 978 String display = null; 979 boolean isValid = isValid(token); 980 if (isValid && tokens != null && tokens.length > 0) { 981 // If we can get a name from tokenizing, then generate an entry from 982 // this. 983 display = tokens[0].getName(); 984 if (!TextUtils.isEmpty(display)) { 985 return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(), 986 isValid); 987 } else { 988 display = tokens[0].getAddress(); 989 if (!TextUtils.isEmpty(display)) { 990 return RecipientEntry.constructFakeEntry(display, isValid); 991 } 992 } 993 } 994 // Unable to validate the token or to create a valid token from it. 995 // Just create a chip the user can edit. 996 String validatedToken = null; 997 if (mValidator != null && !isValid) { 998 // Try fixing up the entry using the validator. 999 validatedToken = mValidator.fixText(token).toString(); 1000 if (!TextUtils.isEmpty(validatedToken)) { 1001 if (validatedToken.contains(token)) { 1002 // protect against the case of a validator with a null 1003 // domain, 1004 // which doesn't add a domain to the token 1005 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken); 1006 if (tokenized.length > 0) { 1007 validatedToken = tokenized[0].getAddress(); 1008 isValid = true; 1009 } 1010 } else { 1011 // We ran into a case where the token was invalid and 1012 // removed 1013 // by the validator. In this case, just use the original 1014 // token 1015 // and let the user sort out the error chip. 1016 validatedToken = null; 1017 isValid = false; 1018 } 1019 } 1020 } 1021 // Otherwise, fallback to just creating an editable email address chip. 1022 return RecipientEntry.constructFakeEntry( 1023 !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid); 1024 } 1025 1026 private boolean isValid(String text) { 1027 return mValidator == null ? true : mValidator.isValid(text); 1028 } 1029 1030 private static String tokenizeAddress(String destination) { 1031 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination); 1032 if (tokens != null && tokens.length > 0) { 1033 return tokens[0].getAddress(); 1034 } 1035 return destination; 1036 } 1037 1038 @Override 1039 public void setTokenizer(Tokenizer tokenizer) { 1040 mTokenizer = tokenizer; 1041 super.setTokenizer(mTokenizer); 1042 } 1043 1044 @Override 1045 public void setValidator(Validator validator) { 1046 mValidator = validator; 1047 super.setValidator(validator); 1048 } 1049 1050 /** 1051 * We cannot use the default mechanism for replaceText. Instead, 1052 * we override onItemClickListener so we can get all the associated 1053 * contact information including display text, address, and id. 1054 */ 1055 @Override 1056 protected void replaceText(CharSequence text) { 1057 return; 1058 } 1059 1060 /** 1061 * Dismiss any selected chips when the back key is pressed. 1062 */ 1063 @Override 1064 public boolean onKeyPreIme(int keyCode, KeyEvent event) { 1065 if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) { 1066 clearSelectedChip(); 1067 return true; 1068 } 1069 return super.onKeyPreIme(keyCode, event); 1070 } 1071 1072 /** 1073 * Monitor key presses in this view to see if the user types 1074 * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. 1075 * If the user has entered text that has contact matches and types 1076 * a commit key, create a chip from the topmost matching contact. 1077 * If the user has entered text that has no contact matches and types 1078 * a commit key, then create a chip from the text they have entered. 1079 */ 1080 @Override 1081 public boolean onKeyUp(int keyCode, KeyEvent event) { 1082 switch (keyCode) { 1083 case KeyEvent.KEYCODE_TAB: 1084 if (event.hasNoModifiers()) { 1085 if (mSelectedChip != null) { 1086 clearSelectedChip(); 1087 } else { 1088 commitDefault(); 1089 } 1090 } 1091 break; 1092 } 1093 return super.onKeyUp(keyCode, event); 1094 } 1095 1096 private boolean focusNext() { 1097 View next = focusSearch(View.FOCUS_DOWN); 1098 if (next != null) { 1099 next.requestFocus(); 1100 return true; 1101 } 1102 return false; 1103 } 1104 1105 /** 1106 * Create a chip from the default selection. If the popup is showing, the 1107 * default is the first item in the popup suggestions list. Otherwise, it is 1108 * whatever the user had typed in. End represents where the the tokenizer 1109 * should search for a token to turn into a chip. 1110 * @return If a chip was created from a real contact. 1111 */ 1112 private boolean commitDefault() { 1113 // If there is no tokenizer, don't try to commit. 1114 if (mTokenizer == null) { 1115 return false; 1116 } 1117 Editable editable = getText(); 1118 int end = getSelectionEnd(); 1119 int start = mTokenizer.findTokenStart(editable, end); 1120 1121 if (shouldCreateChip(start, end)) { 1122 int whatEnd = mTokenizer.findTokenEnd(getText(), start); 1123 // In the middle of chip; treat this as an edit 1124 // and commit the whole token. 1125 whatEnd = movePastTerminators(whatEnd); 1126 if (whatEnd != getSelectionEnd()) { 1127 handleEdit(start, whatEnd); 1128 return true; 1129 } 1130 return commitChip(start, end , editable); 1131 } 1132 return false; 1133 } 1134 1135 private void commitByCharacter() { 1136 // We can't possibly commit by character if we can't tokenize. 1137 if (mTokenizer == null) { 1138 return; 1139 } 1140 Editable editable = getText(); 1141 int end = getSelectionEnd(); 1142 int start = mTokenizer.findTokenStart(editable, end); 1143 if (shouldCreateChip(start, end)) { 1144 commitChip(start, end, editable); 1145 } 1146 setSelection(getText().length()); 1147 } 1148 1149 private boolean commitChip(int start, int end, Editable editable) { 1150 ListAdapter adapter = getAdapter(); 1151 if (adapter != null && adapter.getCount() > 0 && enoughToFilter() 1152 && end == getSelectionEnd() && !isPhoneQuery()) { 1153 // choose the first entry. 1154 submitItemAtPosition(0); 1155 dismissDropDown(); 1156 return true; 1157 } else { 1158 int tokenEnd = mTokenizer.findTokenEnd(editable, start); 1159 if (editable.length() > tokenEnd + 1) { 1160 char charAt = editable.charAt(tokenEnd + 1); 1161 if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) { 1162 tokenEnd++; 1163 } 1164 } 1165 String text = editable.toString().substring(start, tokenEnd).trim(); 1166 clearComposingText(); 1167 if (text != null && text.length() > 0 && !text.equals(" ")) { 1168 RecipientEntry entry = createTokenizedEntry(text); 1169 if (entry != null) { 1170 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1171 CharSequence chipText = createChip(entry, false); 1172 if (chipText != null && start > -1 && end > -1) { 1173 editable.replace(start, end, chipText); 1174 } 1175 } 1176 // Only dismiss the dropdown if it is related to the text we 1177 // just committed. 1178 // For paste, it may not be as there are possibly multiple 1179 // tokens being added. 1180 if (end == getSelectionEnd()) { 1181 dismissDropDown(); 1182 } 1183 sanitizeBetween(); 1184 return true; 1185 } 1186 } 1187 return false; 1188 } 1189 1190 // Visible for testing. 1191 /* package */ void sanitizeBetween() { 1192 // Don't sanitize while we are waiting for content to chipify. 1193 if (mPendingChipsCount > 0) { 1194 return; 1195 } 1196 // Find the last chip. 1197 DrawableRecipientChip[] recips = getSortedRecipients(); 1198 if (recips != null && recips.length > 0) { 1199 DrawableRecipientChip last = recips[recips.length - 1]; 1200 DrawableRecipientChip beforeLast = null; 1201 if (recips.length > 1) { 1202 beforeLast = recips[recips.length - 2]; 1203 } 1204 int startLooking = 0; 1205 int end = getSpannable().getSpanStart(last); 1206 if (beforeLast != null) { 1207 startLooking = getSpannable().getSpanEnd(beforeLast); 1208 Editable text = getText(); 1209 if (startLooking == -1 || startLooking > text.length() - 1) { 1210 // There is nothing after this chip. 1211 return; 1212 } 1213 if (text.charAt(startLooking) == ' ') { 1214 startLooking++; 1215 } 1216 } 1217 if (startLooking >= 0 && end >= 0 && startLooking < end) { 1218 getText().delete(startLooking, end); 1219 } 1220 } 1221 } 1222 1223 private boolean shouldCreateChip(int start, int end) { 1224 return !mNoChips && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end); 1225 } 1226 1227 private boolean alreadyHasChip(int start, int end) { 1228 if (mNoChips) { 1229 return true; 1230 } 1231 DrawableRecipientChip[] chips = 1232 getSpannable().getSpans(start, end, DrawableRecipientChip.class); 1233 if ((chips == null || chips.length == 0)) { 1234 return false; 1235 } 1236 return true; 1237 } 1238 1239 private void handleEdit(int start, int end) { 1240 if (start == -1 || end == -1) { 1241 // This chip no longer exists in the field. 1242 dismissDropDown(); 1243 return; 1244 } 1245 // This is in the middle of a chip, so select out the whole chip 1246 // and commit it. 1247 Editable editable = getText(); 1248 setSelection(end); 1249 String text = getText().toString().substring(start, end); 1250 if (!TextUtils.isEmpty(text)) { 1251 RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text)); 1252 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1253 CharSequence chipText = createChip(entry, false); 1254 int selEnd = getSelectionEnd(); 1255 if (chipText != null && start > -1 && selEnd > -1) { 1256 editable.replace(start, selEnd, chipText); 1257 } 1258 } 1259 dismissDropDown(); 1260 } 1261 1262 /** 1263 * If there is a selected chip, delegate the key events 1264 * to the selected chip. 1265 */ 1266 @Override 1267 public boolean onKeyDown(int keyCode, KeyEvent event) { 1268 if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) { 1269 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 1270 mAlternatesPopup.dismiss(); 1271 } 1272 removeChip(mSelectedChip); 1273 } 1274 1275 switch (keyCode) { 1276 case KeyEvent.KEYCODE_ENTER: 1277 case KeyEvent.KEYCODE_DPAD_CENTER: 1278 if (event.hasNoModifiers()) { 1279 if (commitDefault()) { 1280 return true; 1281 } 1282 if (mSelectedChip != null) { 1283 clearSelectedChip(); 1284 return true; 1285 } else if (focusNext()) { 1286 return true; 1287 } 1288 } 1289 break; 1290 } 1291 1292 return super.onKeyDown(keyCode, event); 1293 } 1294 1295 // Visible for testing. 1296 /* package */ Spannable getSpannable() { 1297 return getText(); 1298 } 1299 1300 private int getChipStart(DrawableRecipientChip chip) { 1301 return getSpannable().getSpanStart(chip); 1302 } 1303 1304 private int getChipEnd(DrawableRecipientChip chip) { 1305 return getSpannable().getSpanEnd(chip); 1306 } 1307 1308 /** 1309 * Instead of filtering on the entire contents of the edit box, 1310 * this subclass method filters on the range from 1311 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 1312 * if the length of that range meets or exceeds {@link #getThreshold} 1313 * and makes sure that the range is not already a Chip. 1314 */ 1315 @Override 1316 protected void performFiltering(CharSequence text, int keyCode) { 1317 boolean isCompletedToken = isCompletedToken(text); 1318 if (enoughToFilter() && !isCompletedToken) { 1319 int end = getSelectionEnd(); 1320 int start = mTokenizer.findTokenStart(text, end); 1321 // If this is a RecipientChip, don't filter 1322 // on its contents. 1323 Spannable span = getSpannable(); 1324 DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class); 1325 if (chips != null && chips.length > 0) { 1326 return; 1327 } 1328 } else if (isCompletedToken) { 1329 return; 1330 } 1331 super.performFiltering(text, keyCode); 1332 } 1333 1334 // Visible for testing. 1335 /*package*/ boolean isCompletedToken(CharSequence text) { 1336 if (TextUtils.isEmpty(text)) { 1337 return false; 1338 } 1339 // Check to see if this is a completed token before filtering. 1340 int end = text.length(); 1341 int start = mTokenizer.findTokenStart(text, end); 1342 String token = text.toString().substring(start, end).trim(); 1343 if (!TextUtils.isEmpty(token)) { 1344 char atEnd = token.charAt(token.length() - 1); 1345 return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON; 1346 } 1347 return false; 1348 } 1349 1350 private void clearSelectedChip() { 1351 if (mSelectedChip != null) { 1352 unselectChip(mSelectedChip); 1353 mSelectedChip = null; 1354 } 1355 setCursorVisible(true); 1356 } 1357 1358 /** 1359 * Monitor touch events in the RecipientEditTextView. 1360 * If the view does not have focus, any tap on the view 1361 * will just focus the view. If the view has focus, determine 1362 * if the touch target is a recipient chip. If it is and the chip 1363 * is not selected, select it and clear any other selected chips. 1364 * If it isn't, then select that chip. 1365 */ 1366 @Override 1367 public boolean onTouchEvent(MotionEvent event) { 1368 if (!isFocused()) { 1369 // Ignore any chip taps until this view is focused. 1370 return super.onTouchEvent(event); 1371 } 1372 boolean handled = super.onTouchEvent(event); 1373 int action = event.getAction(); 1374 boolean chipWasSelected = false; 1375 if (mSelectedChip == null) { 1376 mGestureDetector.onTouchEvent(event); 1377 } 1378 if (mCopyAddress == null && action == MotionEvent.ACTION_UP) { 1379 float x = event.getX(); 1380 float y = event.getY(); 1381 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 1382 DrawableRecipientChip currentChip = findChip(offset); 1383 if (currentChip != null) { 1384 if (action == MotionEvent.ACTION_UP) { 1385 if (mSelectedChip != null && mSelectedChip != currentChip) { 1386 clearSelectedChip(); 1387 mSelectedChip = selectChip(currentChip); 1388 } else if (mSelectedChip == null) { 1389 setSelection(getText().length()); 1390 commitDefault(); 1391 mSelectedChip = selectChip(currentChip); 1392 } else { 1393 onClick(mSelectedChip, offset, x, y); 1394 } 1395 } 1396 chipWasSelected = true; 1397 handled = true; 1398 } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) { 1399 chipWasSelected = true; 1400 } 1401 } 1402 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 1403 clearSelectedChip(); 1404 } 1405 return handled; 1406 } 1407 1408 private void scrollLineIntoView(int line) { 1409 if (mScrollView != null) { 1410 mScrollView.smoothScrollBy(0, calculateOffsetFromBottom(line)); 1411 } 1412 } 1413 1414 private void showAlternates(DrawableRecipientChip currentChip, ListPopupWindow alternatesPopup, 1415 int width) { 1416 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 1417 int bottom; 1418 if (line == getLineCount() -1) { 1419 bottom = 0; 1420 } else { 1421 bottom = -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math.abs(getLineCount() - 1 1422 - line))); 1423 } 1424 // Align the alternates popup with the left side of the View, 1425 // regardless of the position of the chip tapped. 1426 alternatesPopup.setWidth(width); 1427 alternatesPopup.setAnchorView(this); 1428 alternatesPopup.setVerticalOffset(bottom); 1429 alternatesPopup.setAdapter(createAlternatesAdapter(currentChip)); 1430 alternatesPopup.setOnItemClickListener(mAlternatesListener); 1431 // Clear the checked item. 1432 mCheckedItem = -1; 1433 alternatesPopup.show(); 1434 ListView listView = alternatesPopup.getListView(); 1435 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1436 // Checked item would be -1 if the adapter has not 1437 // loaded the view that should be checked yet. The 1438 // variable will be set correctly when onCheckedItemChanged 1439 // is called in a separate thread. 1440 if (mCheckedItem != -1) { 1441 listView.setItemChecked(mCheckedItem, true); 1442 mCheckedItem = -1; 1443 } 1444 } 1445 1446 private ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) { 1447 return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), chip.getDataId(), 1448 ((BaseRecipientAdapter)getAdapter()).getQueryType(), this); 1449 } 1450 1451 private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) { 1452 return new SingleRecipientArrayAdapter(getContext(), mAlternatesLayout, currentChip 1453 .getEntry()); 1454 } 1455 1456 @Override 1457 public void onCheckedItemChanged(int position) { 1458 ListView listView = mAlternatesPopup.getListView(); 1459 if (listView != null && listView.getCheckedItemCount() == 0) { 1460 listView.setItemChecked(position, true); 1461 } 1462 mCheckedItem = position; 1463 } 1464 1465 // TODO: This algorithm will need a lot of tweaking after more people have used 1466 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 1467 // what comes before the finger. 1468 private int putOffsetInRange(int o) { 1469 int offset = o; 1470 Editable text = getText(); 1471 int length = text.length(); 1472 // Remove whitespace from end to find "real end" 1473 int realLength = length; 1474 for (int i = length - 1; i >= 0; i--) { 1475 if (text.charAt(i) == ' ') { 1476 realLength--; 1477 } else { 1478 break; 1479 } 1480 } 1481 1482 // If the offset is beyond or at the end of the text, 1483 // leave it alone. 1484 if (offset >= realLength) { 1485 return offset; 1486 } 1487 Editable editable = getText(); 1488 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 1489 // Keep walking backward! 1490 offset--; 1491 } 1492 return offset; 1493 } 1494 1495 private static int findText(Editable text, int offset) { 1496 if (text.charAt(offset) != ' ') { 1497 return offset; 1498 } 1499 return -1; 1500 } 1501 1502 private DrawableRecipientChip findChip(int offset) { 1503 DrawableRecipientChip[] chips = 1504 getSpannable().getSpans(0, getText().length(), DrawableRecipientChip.class); 1505 // Find the chip that contains this offset. 1506 for (int i = 0; i < chips.length; i++) { 1507 DrawableRecipientChip chip = chips[i]; 1508 int start = getChipStart(chip); 1509 int end = getChipEnd(chip); 1510 if (offset >= start && offset <= end) { 1511 return chip; 1512 } 1513 } 1514 return null; 1515 } 1516 1517 // Visible for testing. 1518 // Use this method to generate text to add to the list of addresses. 1519 /* package */String createAddressText(RecipientEntry entry) { 1520 String display = entry.getDisplayName(); 1521 String address = entry.getDestination(); 1522 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1523 display = null; 1524 } 1525 String trimmedDisplayText; 1526 if (isPhoneQuery() && isPhoneNumber(address)) { 1527 trimmedDisplayText = address.trim(); 1528 } else { 1529 if (address != null) { 1530 // Tokenize out the address in case the address already 1531 // contained the username as well. 1532 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address); 1533 if (tokenized != null && tokenized.length > 0) { 1534 address = tokenized[0].getAddress(); 1535 } 1536 } 1537 Rfc822Token token = new Rfc822Token(display, address, null); 1538 trimmedDisplayText = token.toString().trim(); 1539 } 1540 int index = trimmedDisplayText.indexOf(","); 1541 return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText) 1542 && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer 1543 .terminateToken(trimmedDisplayText) : trimmedDisplayText; 1544 } 1545 1546 // Visible for testing. 1547 // Use this method to generate text to display in a chip. 1548 /*package*/ String createChipDisplayText(RecipientEntry entry) { 1549 String display = entry.getDisplayName(); 1550 String address = entry.getDestination(); 1551 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1552 display = null; 1553 } 1554 if (!TextUtils.isEmpty(display)) { 1555 return display; 1556 } else if (!TextUtils.isEmpty(address)){ 1557 return address; 1558 } else { 1559 return new Rfc822Token(display, address, null).toString(); 1560 } 1561 } 1562 1563 private CharSequence createChip(RecipientEntry entry, boolean pressed) { 1564 String displayText = createAddressText(entry); 1565 if (TextUtils.isEmpty(displayText)) { 1566 return null; 1567 } 1568 SpannableString chipText = null; 1569 // Always leave a blank space at the end of a chip. 1570 int textLength = displayText.length() - 1; 1571 chipText = new SpannableString(displayText); 1572 if (!mNoChips) { 1573 try { 1574 DrawableRecipientChip chip = constructChipSpan(entry, pressed, 1575 false /* leave space for contact icon */); 1576 chipText.setSpan(chip, 0, textLength, 1577 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1578 chip.setOriginalText(chipText.toString()); 1579 } catch (NullPointerException e) { 1580 Log.e(TAG, e.getMessage(), e); 1581 return null; 1582 } 1583 } 1584 return chipText; 1585 } 1586 1587 /** 1588 * When an item in the suggestions list has been clicked, create a chip from the 1589 * contact information of the selected item. 1590 */ 1591 @Override 1592 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 1593 submitItemAtPosition(position); 1594 } 1595 1596 private void submitItemAtPosition(int position) { 1597 RecipientEntry entry = createValidatedEntry( 1598 (RecipientEntry)getAdapter().getItem(position)); 1599 if (entry == null) { 1600 return; 1601 } 1602 clearComposingText(); 1603 1604 int end = getSelectionEnd(); 1605 int start = mTokenizer.findTokenStart(getText(), end); 1606 1607 Editable editable = getText(); 1608 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1609 CharSequence chip = createChip(entry, false); 1610 if (chip != null && start >= 0 && end >= 0) { 1611 editable.replace(start, end, chip); 1612 } 1613 sanitizeBetween(); 1614 } 1615 1616 private RecipientEntry createValidatedEntry(RecipientEntry item) { 1617 if (item == null) { 1618 return null; 1619 } 1620 final RecipientEntry entry; 1621 // If the display name and the address are the same, or if this is a 1622 // valid contact, but the destination is invalid, then make this a fake 1623 // recipient that is editable. 1624 String destination = item.getDestination(); 1625 if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) { 1626 entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(), 1627 destination, item.isValid()); 1628 } else if (RecipientEntry.isCreatedRecipient(item.getContactId()) 1629 && (TextUtils.isEmpty(item.getDisplayName()) 1630 || TextUtils.equals(item.getDisplayName(), destination) 1631 || (mValidator != null && !mValidator.isValid(destination)))) { 1632 entry = RecipientEntry.constructFakeEntry(destination, item.isValid()); 1633 } else { 1634 entry = item; 1635 } 1636 return entry; 1637 } 1638 1639 /** Returns a collection of contact Id for each chip inside this View. */ 1640 /* package */ Collection<Long> getContactIds() { 1641 final Set<Long> result = new HashSet<Long>(); 1642 DrawableRecipientChip[] chips = getSortedRecipients(); 1643 if (chips != null) { 1644 for (DrawableRecipientChip chip : chips) { 1645 result.add(chip.getContactId()); 1646 } 1647 } 1648 return result; 1649 } 1650 1651 1652 /** Returns a collection of data Id for each chip inside this View. May be null. */ 1653 /* package */ Collection<Long> getDataIds() { 1654 final Set<Long> result = new HashSet<Long>(); 1655 DrawableRecipientChip [] chips = getSortedRecipients(); 1656 if (chips != null) { 1657 for (DrawableRecipientChip chip : chips) { 1658 result.add(chip.getDataId()); 1659 } 1660 } 1661 return result; 1662 } 1663 1664 // Visible for testing. 1665 /* package */DrawableRecipientChip[] getSortedRecipients() { 1666 DrawableRecipientChip[] recips = getSpannable() 1667 .getSpans(0, getText().length(), DrawableRecipientChip.class); 1668 ArrayList<DrawableRecipientChip> recipientsList = new ArrayList<DrawableRecipientChip>( 1669 Arrays.asList(recips)); 1670 final Spannable spannable = getSpannable(); 1671 Collections.sort(recipientsList, new Comparator<DrawableRecipientChip>() { 1672 1673 @Override 1674 public int compare(DrawableRecipientChip first, DrawableRecipientChip second) { 1675 int firstStart = spannable.getSpanStart(first); 1676 int secondStart = spannable.getSpanStart(second); 1677 if (firstStart < secondStart) { 1678 return -1; 1679 } else if (firstStart > secondStart) { 1680 return 1; 1681 } else { 1682 return 0; 1683 } 1684 } 1685 }); 1686 return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]); 1687 } 1688 1689 @Override 1690 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 1691 return false; 1692 } 1693 1694 @Override 1695 public void onDestroyActionMode(ActionMode mode) { 1696 } 1697 1698 @Override 1699 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 1700 return false; 1701 } 1702 1703 /** 1704 * No chips are selectable. 1705 */ 1706 @Override 1707 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 1708 return false; 1709 } 1710 1711 // Visible for testing. 1712 /* package */ImageSpan getMoreChip() { 1713 MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(), 1714 MoreImageSpan.class); 1715 return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null; 1716 } 1717 1718 private MoreImageSpan createMoreSpan(int count) { 1719 String moreText = String.format(mMoreItem.getText().toString(), count); 1720 TextPaint morePaint = new TextPaint(getPaint()); 1721 morePaint.setTextSize(mMoreItem.getTextSize()); 1722 morePaint.setColor(mMoreItem.getCurrentTextColor()); 1723 int width = (int)morePaint.measureText(moreText) + mMoreItem.getPaddingLeft() 1724 + mMoreItem.getPaddingRight(); 1725 int height = getLineHeight(); 1726 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 1727 Canvas canvas = new Canvas(drawable); 1728 int adjustedHeight = height; 1729 Layout layout = getLayout(); 1730 if (layout != null) { 1731 adjustedHeight -= layout.getLineDescent(0); 1732 } 1733 canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, morePaint); 1734 1735 Drawable result = new BitmapDrawable(getResources(), drawable); 1736 result.setBounds(0, 0, width, height); 1737 return new MoreImageSpan(result); 1738 } 1739 1740 // Visible for testing. 1741 /*package*/ void createMoreChipPlainText() { 1742 // Take the first <= CHIP_LIMIT addresses and get to the end of the second one. 1743 Editable text = getText(); 1744 int start = 0; 1745 int end = start; 1746 for (int i = 0; i < CHIP_LIMIT; i++) { 1747 end = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 1748 start = end; // move to the next token and get its end. 1749 } 1750 // Now, count total addresses. 1751 start = 0; 1752 int tokenCount = countTokens(text); 1753 MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT); 1754 SpannableString chipText = new SpannableString(text.subSequence(end, text.length())); 1755 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1756 text.replace(end, text.length(), chipText); 1757 mMoreChip = moreSpan; 1758 } 1759 1760 // Visible for testing. 1761 /* package */int countTokens(Editable text) { 1762 int tokenCount = 0; 1763 int start = 0; 1764 while (start < text.length()) { 1765 start = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 1766 tokenCount++; 1767 if (start >= text.length()) { 1768 break; 1769 } 1770 } 1771 return tokenCount; 1772 } 1773 1774 /** 1775 * Create the more chip. The more chip is text that replaces any chips that 1776 * do not fit in the pre-defined available space when the 1777 * RecipientEditTextView loses focus. 1778 */ 1779 // Visible for testing. 1780 /* package */ void createMoreChip() { 1781 if (mNoChips) { 1782 createMoreChipPlainText(); 1783 return; 1784 } 1785 1786 if (!mShouldShrink) { 1787 return; 1788 } 1789 ImageSpan[] tempMore = getSpannable().getSpans(0, getText().length(), MoreImageSpan.class); 1790 if (tempMore.length > 0) { 1791 getSpannable().removeSpan(tempMore[0]); 1792 } 1793 DrawableRecipientChip[] recipients = getSortedRecipients(); 1794 1795 if (recipients == null || recipients.length <= CHIP_LIMIT) { 1796 mMoreChip = null; 1797 return; 1798 } 1799 Spannable spannable = getSpannable(); 1800 int numRecipients = recipients.length; 1801 int overage = numRecipients - CHIP_LIMIT; 1802 MoreImageSpan moreSpan = createMoreSpan(overage); 1803 mRemovedSpans = new ArrayList<DrawableRecipientChip>(); 1804 int totalReplaceStart = 0; 1805 int totalReplaceEnd = 0; 1806 Editable text = getText(); 1807 for (int i = numRecipients - overage; i < recipients.length; i++) { 1808 mRemovedSpans.add(recipients[i]); 1809 if (i == numRecipients - overage) { 1810 totalReplaceStart = spannable.getSpanStart(recipients[i]); 1811 } 1812 if (i == recipients.length - 1) { 1813 totalReplaceEnd = spannable.getSpanEnd(recipients[i]); 1814 } 1815 if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) { 1816 int spanStart = spannable.getSpanStart(recipients[i]); 1817 int spanEnd = spannable.getSpanEnd(recipients[i]); 1818 recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd)); 1819 } 1820 spannable.removeSpan(recipients[i]); 1821 } 1822 if (totalReplaceEnd < text.length()) { 1823 totalReplaceEnd = text.length(); 1824 } 1825 int end = Math.max(totalReplaceStart, totalReplaceEnd); 1826 int start = Math.min(totalReplaceStart, totalReplaceEnd); 1827 SpannableString chipText = new SpannableString(text.subSequence(start, end)); 1828 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1829 text.replace(start, end, chipText); 1830 mMoreChip = moreSpan; 1831 // If adding the +more chip goes over the limit, resize accordingly. 1832 if (!isPhoneQuery() && getLineCount() > mMaxLines) { 1833 setMaxLines(getLineCount()); 1834 } 1835 } 1836 1837 /** 1838 * Replace the more chip, if it exists, with all of the recipient chips it had 1839 * replaced when the RecipientEditTextView gains focus. 1840 */ 1841 // Visible for testing. 1842 /*package*/ void removeMoreChip() { 1843 if (mMoreChip != null) { 1844 Spannable span = getSpannable(); 1845 span.removeSpan(mMoreChip); 1846 mMoreChip = null; 1847 // Re-add the spans that were removed. 1848 if (mRemovedSpans != null && mRemovedSpans.size() > 0) { 1849 // Recreate each removed span. 1850 DrawableRecipientChip[] recipients = getSortedRecipients(); 1851 // Start the search for tokens after the last currently visible 1852 // chip. 1853 if (recipients == null || recipients.length == 0) { 1854 return; 1855 } 1856 int end = span.getSpanEnd(recipients[recipients.length - 1]); 1857 Editable editable = getText(); 1858 for (DrawableRecipientChip chip : mRemovedSpans) { 1859 int chipStart; 1860 int chipEnd; 1861 String token; 1862 // Need to find the location of the chip, again. 1863 token = (String) chip.getOriginalText(); 1864 // As we find the matching recipient for the remove spans, 1865 // reduce the size of the string we need to search. 1866 // That way, if there are duplicates, we always find the correct 1867 // recipient. 1868 chipStart = editable.toString().indexOf(token, end); 1869 end = chipEnd = Math.min(editable.length(), chipStart + token.length()); 1870 // Only set the span if we found a matching token. 1871 if (chipStart != -1) { 1872 editable.setSpan(chip, chipStart, chipEnd, 1873 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1874 } 1875 } 1876 mRemovedSpans.clear(); 1877 } 1878 } 1879 } 1880 1881 /** 1882 * Show specified chip as selected. If the RecipientChip is just an email address, 1883 * selecting the chip will take the contents of the chip and place it at 1884 * the end of the RecipientEditTextView for inline editing. If the 1885 * RecipientChip is a complete contact, then selecting the chip 1886 * will change the background color of the chip, show the delete icon, 1887 * and a popup window with the address in use highlighted and any other 1888 * alternate addresses for the contact. 1889 * @param currentChip Chip to select. 1890 * @return A RecipientChip in the selected state or null if the chip 1891 * just contained an email address. 1892 */ 1893 private DrawableRecipientChip selectChip(DrawableRecipientChip currentChip) { 1894 if (shouldShowEditableText(currentChip)) { 1895 CharSequence text = currentChip.getValue(); 1896 Editable editable = getText(); 1897 Spannable spannable = getSpannable(); 1898 int spanStart = spannable.getSpanStart(currentChip); 1899 int spanEnd = spannable.getSpanEnd(currentChip); 1900 spannable.removeSpan(currentChip); 1901 editable.delete(spanStart, spanEnd); 1902 setCursorVisible(true); 1903 setSelection(editable.length()); 1904 editable.append(text); 1905 return constructChipSpan( 1906 RecipientEntry.constructFakeEntry((String) text, isValid(text.toString())), 1907 true, false); 1908 } else if (currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT) { 1909 int start = getChipStart(currentChip); 1910 int end = getChipEnd(currentChip); 1911 getSpannable().removeSpan(currentChip); 1912 DrawableRecipientChip newChip; 1913 try { 1914 if (mNoChips) { 1915 return null; 1916 } 1917 newChip = constructChipSpan(currentChip.getEntry(), true, false); 1918 } catch (NullPointerException e) { 1919 Log.e(TAG, e.getMessage(), e); 1920 return null; 1921 } 1922 Editable editable = getText(); 1923 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1924 if (start == -1 || end == -1) { 1925 Log.d(TAG, "The chip being selected no longer exists but should."); 1926 } else { 1927 editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1928 } 1929 newChip.setSelected(true); 1930 if (shouldShowEditableText(newChip)) { 1931 scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip))); 1932 } 1933 showAddress(newChip, mAddressPopup, getWidth()); 1934 setCursorVisible(false); 1935 return newChip; 1936 } else { 1937 int start = getChipStart(currentChip); 1938 int end = getChipEnd(currentChip); 1939 getSpannable().removeSpan(currentChip); 1940 DrawableRecipientChip newChip; 1941 try { 1942 newChip = constructChipSpan(currentChip.getEntry(), true, false); 1943 } catch (NullPointerException e) { 1944 Log.e(TAG, e.getMessage(), e); 1945 return null; 1946 } 1947 Editable editable = getText(); 1948 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1949 if (start == -1 || end == -1) { 1950 Log.d(TAG, "The chip being selected no longer exists but should."); 1951 } else { 1952 editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1953 } 1954 newChip.setSelected(true); 1955 if (shouldShowEditableText(newChip)) { 1956 scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip))); 1957 } 1958 showAlternates(newChip, mAlternatesPopup, getWidth()); 1959 setCursorVisible(false); 1960 return newChip; 1961 } 1962 } 1963 1964 private boolean shouldShowEditableText(DrawableRecipientChip currentChip) { 1965 long contactId = currentChip.getContactId(); 1966 return contactId == RecipientEntry.INVALID_CONTACT 1967 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 1968 } 1969 1970 private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup, 1971 int width) { 1972 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 1973 int bottom = calculateOffsetFromBottom(line); 1974 // Align the alternates popup with the left side of the View, 1975 // regardless of the position of the chip tapped. 1976 popup.setWidth(width); 1977 popup.setAnchorView(this); 1978 popup.setVerticalOffset(bottom); 1979 popup.setAdapter(createSingleAddressAdapter(currentChip)); 1980 popup.setOnItemClickListener(new OnItemClickListener() { 1981 @Override 1982 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 1983 unselectChip(currentChip); 1984 popup.dismiss(); 1985 } 1986 }); 1987 popup.show(); 1988 ListView listView = popup.getListView(); 1989 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1990 listView.setItemChecked(0, true); 1991 } 1992 1993 /** 1994 * Remove selection from this chip. Unselecting a RecipientChip will render 1995 * the chip without a delete icon and with an unfocused background. This is 1996 * called when the RecipientChip no longer has focus. 1997 */ 1998 private void unselectChip(DrawableRecipientChip chip) { 1999 int start = getChipStart(chip); 2000 int end = getChipEnd(chip); 2001 Editable editable = getText(); 2002 mSelectedChip = null; 2003 if (start == -1 || end == -1) { 2004 Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing"); 2005 setSelection(editable.length()); 2006 commitDefault(); 2007 } else { 2008 getSpannable().removeSpan(chip); 2009 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 2010 editable.removeSpan(chip); 2011 try { 2012 if (!mNoChips) { 2013 editable.setSpan(constructChipSpan(chip.getEntry(), false, false), 2014 start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2015 } 2016 } catch (NullPointerException e) { 2017 Log.e(TAG, e.getMessage(), e); 2018 } 2019 } 2020 setCursorVisible(true); 2021 setSelection(editable.length()); 2022 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 2023 mAlternatesPopup.dismiss(); 2024 } 2025 } 2026 2027 /** 2028 * Return whether a touch event was inside the delete target of 2029 * a selected chip. It is in the delete target if: 2030 * 1) the x and y points of the event are within the 2031 * delete assset. 2032 * 2) the point tapped would have caused a cursor to appear 2033 * right after the selected chip. 2034 * @return boolean 2035 */ 2036 private boolean isInDelete(DrawableRecipientChip chip, int offset, float x, float y) { 2037 // Figure out the bounds of this chip and whether or not 2038 // the user clicked in the X portion. 2039 // TODO: Should x and y be used, or removed? 2040 return chip.isSelected() && offset == getChipEnd(chip); 2041 } 2042 2043 /** 2044 * Remove the chip and any text associated with it from the RecipientEditTextView. 2045 */ 2046 // Visible for testing. 2047 /*pacakge*/ void removeChip(DrawableRecipientChip chip) { 2048 Spannable spannable = getSpannable(); 2049 int spanStart = spannable.getSpanStart(chip); 2050 int spanEnd = spannable.getSpanEnd(chip); 2051 Editable text = getText(); 2052 int toDelete = spanEnd; 2053 boolean wasSelected = chip == mSelectedChip; 2054 // Clear that there is a selected chip before updating any text. 2055 if (wasSelected) { 2056 mSelectedChip = null; 2057 } 2058 // Always remove trailing spaces when removing a chip. 2059 while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') { 2060 toDelete++; 2061 } 2062 spannable.removeSpan(chip); 2063 if (spanStart >= 0 && toDelete > 0) { 2064 text.delete(spanStart, toDelete); 2065 } 2066 if (wasSelected) { 2067 clearSelectedChip(); 2068 } 2069 } 2070 2071 /** 2072 * Replace this currently selected chip with a new chip 2073 * that uses the contact data provided. 2074 */ 2075 // Visible for testing. 2076 /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) { 2077 boolean wasSelected = chip == mSelectedChip; 2078 if (wasSelected) { 2079 mSelectedChip = null; 2080 } 2081 int start = getChipStart(chip); 2082 int end = getChipEnd(chip); 2083 getSpannable().removeSpan(chip); 2084 Editable editable = getText(); 2085 CharSequence chipText = createChip(entry, false); 2086 if (chipText != null) { 2087 if (start == -1 || end == -1) { 2088 Log.e(TAG, "The chip to replace does not exist but should."); 2089 editable.insert(0, chipText); 2090 } else { 2091 if (!TextUtils.isEmpty(chipText)) { 2092 // There may be a space to replace with this chip's new 2093 // associated space. Check for it 2094 int toReplace = end; 2095 while (toReplace >= 0 && toReplace < editable.length() 2096 && editable.charAt(toReplace) == ' ') { 2097 toReplace++; 2098 } 2099 editable.replace(start, toReplace, chipText); 2100 } 2101 } 2102 } 2103 setCursorVisible(true); 2104 if (wasSelected) { 2105 clearSelectedChip(); 2106 } 2107 } 2108 2109 /** 2110 * Handle click events for a chip. When a selected chip receives a click 2111 * event, see if that event was in the delete icon. If so, delete it. 2112 * Otherwise, unselect the chip. 2113 */ 2114 public void onClick(DrawableRecipientChip chip, int offset, float x, float y) { 2115 if (chip.isSelected()) { 2116 if (isInDelete(chip, offset, x, y)) { 2117 removeChip(chip); 2118 } else { 2119 clearSelectedChip(); 2120 } 2121 } 2122 } 2123 2124 private boolean chipsPending() { 2125 return mPendingChipsCount > 0 || (mRemovedSpans != null && mRemovedSpans.size() > 0); 2126 } 2127 2128 @Override 2129 public void removeTextChangedListener(TextWatcher watcher) { 2130 mTextWatcher = null; 2131 super.removeTextChangedListener(watcher); 2132 } 2133 2134 private class RecipientTextWatcher implements TextWatcher { 2135 2136 @Override 2137 public void afterTextChanged(Editable s) { 2138 // If the text has been set to null or empty, make sure we remove 2139 // all the spans we applied. 2140 if (TextUtils.isEmpty(s)) { 2141 // Remove all the chips spans. 2142 Spannable spannable = getSpannable(); 2143 DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(), 2144 DrawableRecipientChip.class); 2145 for (DrawableRecipientChip chip : chips) { 2146 spannable.removeSpan(chip); 2147 } 2148 if (mMoreChip != null) { 2149 spannable.removeSpan(mMoreChip); 2150 } 2151 return; 2152 } 2153 // Get whether there are any recipients pending addition to the 2154 // view. If there are, don't do anything in the text watcher. 2155 if (chipsPending()) { 2156 return; 2157 } 2158 // If the user is editing a chip, don't clear it. 2159 if (mSelectedChip != null) { 2160 if (!isGeneratedContact(mSelectedChip)) { 2161 setCursorVisible(true); 2162 setSelection(getText().length()); 2163 clearSelectedChip(); 2164 } else { 2165 return; 2166 } 2167 } 2168 int length = s.length(); 2169 // Make sure there is content there to parse and that it is 2170 // not just the commit character. 2171 if (length > 1) { 2172 if (lastCharacterIsCommitCharacter(s)) { 2173 commitByCharacter(); 2174 return; 2175 } 2176 char last; 2177 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2178 int len = length() - 1; 2179 if (end != len) { 2180 last = s.charAt(end); 2181 } else { 2182 last = s.charAt(len); 2183 } 2184 if (last == COMMIT_CHAR_SPACE) { 2185 if (!isPhoneQuery()) { 2186 // Check if this is a valid email address. If it is, 2187 // commit it. 2188 String text = getText().toString(); 2189 int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2190 String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text, 2191 tokenStart)); 2192 if (!TextUtils.isEmpty(sub) && mValidator != null && 2193 mValidator.isValid(sub)) { 2194 commitByCharacter(); 2195 } 2196 } 2197 } 2198 } 2199 } 2200 2201 @Override 2202 public void onTextChanged(CharSequence s, int start, int before, int count) { 2203 // The user deleted some text OR some text was replaced; check to 2204 // see if the insertion point is on a space 2205 // following a chip. 2206 if (before - count == 1) { 2207 // If the item deleted is a space, and the thing before the 2208 // space is a chip, delete the entire span. 2209 int selStart = getSelectionStart(); 2210 DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart, 2211 DrawableRecipientChip.class); 2212 if (repl.length > 0) { 2213 // There is a chip there! Just remove it. 2214 Editable editable = getText(); 2215 // Add the separator token. 2216 int tokenStart = mTokenizer.findTokenStart(editable, selStart); 2217 int tokenEnd = mTokenizer.findTokenEnd(editable, tokenStart); 2218 tokenEnd = tokenEnd + 1; 2219 if (tokenEnd > editable.length()) { 2220 tokenEnd = editable.length(); 2221 } 2222 editable.delete(tokenStart, tokenEnd); 2223 getSpannable().removeSpan(repl[0]); 2224 } 2225 } else if (count > before) { 2226 if (mSelectedChip != null 2227 && isGeneratedContact(mSelectedChip)) { 2228 if (lastCharacterIsCommitCharacter(s)) { 2229 commitByCharacter(); 2230 return; 2231 } 2232 } 2233 } 2234 } 2235 2236 @Override 2237 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 2238 // Do nothing. 2239 } 2240 } 2241 2242 @Override 2243 public <T extends ListAdapter & Filterable> void setAdapter(T adapter) { 2244 super.setAdapter(adapter); 2245 ((BaseRecipientAdapter) adapter) 2246 .registerUpdateObserver(new BaseRecipientAdapter.EntriesUpdatedObserver() { 2247 @Override 2248 public void onChanged(List<RecipientEntry> entries) { 2249 if (entries != null && entries.size() > 0) { 2250 scrollBottomIntoView(); 2251 } 2252 } 2253 }); 2254 } 2255 2256 private void scrollBottomIntoView() { 2257 if (mScrollView != null && mShouldShrink) { 2258 int[] location = new int[2]; 2259 getLocationOnScreen(location); 2260 int height = getHeight(); 2261 int currentPos = location[1] + height; 2262 // Desired position shows at least 1 line of chips below the action 2263 // bar. 2264 // We add excess padding to make sure this is always below other 2265 // content. 2266 int desiredPos = (int) mChipHeight + mActionBarHeight + getExcessTopPadding(); 2267 if (currentPos > desiredPos) { 2268 mScrollView.scrollBy(0, currentPos - desiredPos); 2269 } 2270 } 2271 } 2272 2273 private int getExcessTopPadding() { 2274 if (sExcessTopPadding == -1) { 2275 sExcessTopPadding = (int) (mChipHeight + mLineSpacingExtra); 2276 } 2277 return sExcessTopPadding; 2278 } 2279 2280 public boolean lastCharacterIsCommitCharacter(CharSequence s) { 2281 char last; 2282 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2283 int len = length() - 1; 2284 if (end != len) { 2285 last = s.charAt(end); 2286 } else { 2287 last = s.charAt(len); 2288 } 2289 return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON; 2290 } 2291 2292 public boolean isGeneratedContact(DrawableRecipientChip chip) { 2293 long contactId = chip.getContactId(); 2294 return contactId == RecipientEntry.INVALID_CONTACT 2295 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 2296 } 2297 2298 /** 2299 * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}. 2300 */ 2301 private void handlePasteClip(ClipData clip) { 2302 removeTextChangedListener(mTextWatcher); 2303 2304 if (clip != null && clip.getDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)){ 2305 for (int i = 0; i < clip.getItemCount(); i++) { 2306 CharSequence paste = clip.getItemAt(i).getText(); 2307 if (paste != null) { 2308 int start = getSelectionStart(); 2309 int end = getSelectionEnd(); 2310 Editable editable = getText(); 2311 if (start >= 0 && end >= 0 && start != end) { 2312 editable.append(paste, start, end); 2313 } else { 2314 editable.insert(end, paste); 2315 } 2316 handlePasteAndReplace(); 2317 } 2318 } 2319 } 2320 2321 mHandler.post(mAddTextWatcher); 2322 } 2323 2324 @Override 2325 public boolean onTextContextMenuItem(int id) { 2326 if (id == android.R.id.paste) { 2327 ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService( 2328 Context.CLIPBOARD_SERVICE); 2329 handlePasteClip(clipboard.getPrimaryClip()); 2330 return true; 2331 } 2332 return super.onTextContextMenuItem(id); 2333 } 2334 2335 private void handlePasteAndReplace() { 2336 ArrayList<DrawableRecipientChip> created = handlePaste(); 2337 if (created != null && created.size() > 0) { 2338 // Perform reverse lookups on the pasted contacts. 2339 IndividualReplacementTask replace = new IndividualReplacementTask(); 2340 replace.execute(created); 2341 } 2342 } 2343 2344 // Visible for testing. 2345 /* package */ArrayList<DrawableRecipientChip> handlePaste() { 2346 String text = getText().toString(); 2347 int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2348 String lastAddress = text.substring(originalTokenStart); 2349 int tokenStart = originalTokenStart; 2350 int prevTokenStart = 0; 2351 DrawableRecipientChip findChip = null; 2352 ArrayList<DrawableRecipientChip> created = new ArrayList<DrawableRecipientChip>(); 2353 if (tokenStart != 0) { 2354 // There are things before this! 2355 while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) { 2356 prevTokenStart = tokenStart; 2357 tokenStart = mTokenizer.findTokenStart(text, tokenStart); 2358 findChip = findChip(tokenStart); 2359 } 2360 if (tokenStart != originalTokenStart) { 2361 if (findChip != null) { 2362 tokenStart = prevTokenStart; 2363 } 2364 int tokenEnd; 2365 DrawableRecipientChip createdChip; 2366 while (tokenStart < originalTokenStart) { 2367 tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(), 2368 tokenStart)); 2369 commitChip(tokenStart, tokenEnd, getText()); 2370 createdChip = findChip(tokenStart); 2371 if (createdChip == null) { 2372 break; 2373 } 2374 // +1 for the space at the end. 2375 tokenStart = getSpannable().getSpanEnd(createdChip) + 1; 2376 created.add(createdChip); 2377 } 2378 } 2379 } 2380 // Take a look at the last token. If the token has been completed with a 2381 // commit character, create a chip. 2382 if (isCompletedToken(lastAddress)) { 2383 Editable editable = getText(); 2384 tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart); 2385 commitChip(tokenStart, editable.length(), editable); 2386 created.add(findChip(tokenStart)); 2387 } 2388 return created; 2389 } 2390 2391 // Visible for testing. 2392 /* package */int movePastTerminators(int tokenEnd) { 2393 if (tokenEnd >= length()) { 2394 return tokenEnd; 2395 } 2396 char atEnd = getText().toString().charAt(tokenEnd); 2397 if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) { 2398 tokenEnd++; 2399 } 2400 // This token had not only an end token character, but also a space 2401 // separating it from the next token. 2402 if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') { 2403 tokenEnd++; 2404 } 2405 return tokenEnd; 2406 } 2407 2408 private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> { 2409 private DrawableRecipientChip createFreeChip(RecipientEntry entry) { 2410 try { 2411 if (mNoChips) { 2412 return null; 2413 } 2414 return constructChipSpan(entry, false, 2415 false /*leave space for contact icon */); 2416 } catch (NullPointerException e) { 2417 Log.e(TAG, e.getMessage(), e); 2418 return null; 2419 } 2420 } 2421 2422 @Override 2423 protected void onPreExecute() { 2424 // Ensure everything is in chip-form already, so we don't have text that slowly gets 2425 // replaced 2426 final List<DrawableRecipientChip> originalRecipients = 2427 new ArrayList<DrawableRecipientChip>(); 2428 final DrawableRecipientChip[] existingChips = getSortedRecipients(); 2429 for (int i = 0; i < existingChips.length; i++) { 2430 originalRecipients.add(existingChips[i]); 2431 } 2432 if (mRemovedSpans != null) { 2433 originalRecipients.addAll(mRemovedSpans); 2434 } 2435 2436 final List<DrawableRecipientChip> replacements = 2437 new ArrayList<DrawableRecipientChip>(originalRecipients.size()); 2438 2439 for (final DrawableRecipientChip chip : originalRecipients) { 2440 if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId()) 2441 && getSpannable().getSpanStart(chip) != -1) { 2442 replacements.add(createFreeChip(chip.getEntry())); 2443 } else { 2444 replacements.add(null); 2445 } 2446 } 2447 2448 processReplacements(originalRecipients, replacements); 2449 } 2450 2451 @Override 2452 protected Void doInBackground(Void... params) { 2453 if (mIndividualReplacements != null) { 2454 mIndividualReplacements.cancel(true); 2455 } 2456 // For each chip in the list, look up the matching contact. 2457 // If there is a match, replace that chip with the matching 2458 // chip. 2459 final ArrayList<DrawableRecipientChip> recipients = 2460 new ArrayList<DrawableRecipientChip>(); 2461 DrawableRecipientChip[] existingChips = getSortedRecipients(); 2462 for (int i = 0; i < existingChips.length; i++) { 2463 recipients.add(existingChips[i]); 2464 } 2465 if (mRemovedSpans != null) { 2466 recipients.addAll(mRemovedSpans); 2467 } 2468 ArrayList<String> addresses = new ArrayList<String>(); 2469 DrawableRecipientChip chip; 2470 for (int i = 0; i < recipients.size(); i++) { 2471 chip = recipients.get(i); 2472 if (chip != null) { 2473 addresses.add(createAddressText(chip.getEntry())); 2474 } 2475 } 2476 RecipientAlternatesAdapter.getMatchingRecipients(getContext(), addresses, 2477 ((BaseRecipientAdapter) getAdapter()).getAccount(), 2478 new RecipientMatchCallback() { 2479 2480 @Override 2481 public void matchesFound(Map<String, RecipientEntry> entries) { 2482 final ArrayList<DrawableRecipientChip> replacements = 2483 new ArrayList<DrawableRecipientChip>(); 2484 for (final DrawableRecipientChip temp : recipients) { 2485 RecipientEntry entry = null; 2486 if (temp != null && RecipientEntry.isCreatedRecipient( 2487 temp.getEntry().getContactId()) 2488 && getSpannable().getSpanStart(temp) != -1) { 2489 // Replace this. 2490 entry = createValidatedEntry( 2491 entries.get(tokenizeAddress(temp.getEntry() 2492 .getDestination()))); 2493 } 2494 if (entry != null) { 2495 replacements.add(createFreeChip(entry)); 2496 } else { 2497 replacements.add(null); 2498 } 2499 } 2500 processReplacements(recipients, replacements); 2501 } 2502 2503 @Override 2504 public void matchesNotFound(final Set<String> unfoundAddresses) { 2505 final List<DrawableRecipientChip> replacements = 2506 new ArrayList<DrawableRecipientChip>(unfoundAddresses.size()); 2507 2508 for (final DrawableRecipientChip temp : recipients) { 2509 if (temp != null && RecipientEntry.isCreatedRecipient( 2510 temp.getEntry().getContactId()) 2511 && getSpannable().getSpanStart(temp) != -1) { 2512 if (unfoundAddresses.contains( 2513 temp.getEntry().getDestination())) { 2514 replacements.add(createFreeChip(temp.getEntry())); 2515 } else { 2516 replacements.add(null); 2517 } 2518 } else { 2519 replacements.add(null); 2520 } 2521 } 2522 2523 processReplacements(recipients, replacements); 2524 } 2525 }); 2526 return null; 2527 } 2528 2529 private void processReplacements(final List<DrawableRecipientChip> recipients, 2530 final List<DrawableRecipientChip> replacements) { 2531 if (replacements != null && replacements.size() > 0) { 2532 final Runnable runnable = new Runnable() { 2533 @Override 2534 public void run() { 2535 Editable oldText = getText(); 2536 int start, end; 2537 int i = 0; 2538 for (DrawableRecipientChip chip : recipients) { 2539 DrawableRecipientChip replacement = replacements.get(i); 2540 if (replacement != null) { 2541 final RecipientEntry oldEntry = chip.getEntry(); 2542 final RecipientEntry newEntry = replacement.getEntry(); 2543 final boolean isBetter = 2544 RecipientAlternatesAdapter.getBetterRecipient( 2545 oldEntry, newEntry) == newEntry; 2546 2547 if (isBetter) { 2548 // Find the location of the chip in the text currently shown. 2549 start = oldText.getSpanStart(chip); 2550 if (start != -1) { 2551 // Replacing the entirety of what the chip represented, 2552 // including the extra space dividing it from other chips. 2553 end = oldText.getSpanEnd(chip) + 1; 2554 oldText.removeSpan(chip); 2555 // Make sure we always have just 1 space at the end to 2556 // separate this chip from the next chip. 2557 SpannableString displayText = 2558 new SpannableString(createAddressText( 2559 replacement.getEntry()).trim() 2560 + " "); 2561 displayText.setSpan(replacement, 0, 2562 displayText.length() - 1, 2563 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2564 // Replace the old text we found with with the new display 2565 // text, which now may also contain the display name of the 2566 // recipient. 2567 oldText.replace(start, end, displayText); 2568 replacement.setOriginalText(displayText.toString()); 2569 replacements.set(i, null); 2570 2571 recipients.set(i, replacement); 2572 } 2573 } 2574 } 2575 i++; 2576 } 2577 } 2578 }; 2579 2580 if (Looper.myLooper() == Looper.getMainLooper()) { 2581 runnable.run(); 2582 } else { 2583 mHandler.post(runnable); 2584 } 2585 } 2586 } 2587 } 2588 2589 private class IndividualReplacementTask 2590 extends AsyncTask<ArrayList<DrawableRecipientChip>, Void, Void> { 2591 @Override 2592 protected Void doInBackground(ArrayList<DrawableRecipientChip>... params) { 2593 // For each chip in the list, look up the matching contact. 2594 // If there is a match, replace that chip with the matching 2595 // chip. 2596 final ArrayList<DrawableRecipientChip> originalRecipients = params[0]; 2597 ArrayList<String> addresses = new ArrayList<String>(); 2598 DrawableRecipientChip chip; 2599 for (int i = 0; i < originalRecipients.size(); i++) { 2600 chip = originalRecipients.get(i); 2601 if (chip != null) { 2602 addresses.add(createAddressText(chip.getEntry())); 2603 } 2604 } 2605 RecipientAlternatesAdapter.getMatchingRecipients(getContext(), addresses, 2606 ((BaseRecipientAdapter) getAdapter()).getAccount(), 2607 new RecipientMatchCallback() { 2608 2609 @Override 2610 public void matchesFound(Map<String, RecipientEntry> entries) { 2611 for (final DrawableRecipientChip temp : originalRecipients) { 2612 if (RecipientEntry.isCreatedRecipient(temp.getEntry() 2613 .getContactId()) 2614 && getSpannable().getSpanStart(temp) != -1) { 2615 // Replace this. 2616 RecipientEntry entry = createValidatedEntry(entries 2617 .get(tokenizeAddress(temp.getEntry().getDestination()) 2618 .toLowerCase())); 2619 // If we don't have a validated contact 2620 // match, just use the 2621 // entry as it existed before. 2622 if (entry == null && !isPhoneQuery()) { 2623 entry = temp.getEntry(); 2624 } 2625 final RecipientEntry tempEntry = entry; 2626 if (tempEntry != null) { 2627 mHandler.post(new Runnable() { 2628 @Override 2629 public void run() { 2630 replaceChip(temp, tempEntry); 2631 } 2632 }); 2633 } 2634 } 2635 } 2636 } 2637 2638 @Override 2639 public void matchesNotFound(final Set<String> unfoundAddresses) { 2640 // No action required 2641 } 2642 }); 2643 return null; 2644 } 2645 } 2646 2647 2648 /** 2649 * MoreImageSpan is a simple class created for tracking the existence of a 2650 * more chip across activity restarts/ 2651 */ 2652 private class MoreImageSpan extends ImageSpan { 2653 public MoreImageSpan(Drawable b) { 2654 super(b); 2655 } 2656 } 2657 2658 @Override 2659 public boolean onDown(MotionEvent e) { 2660 return false; 2661 } 2662 2663 @Override 2664 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { 2665 // Do nothing. 2666 return false; 2667 } 2668 2669 @Override 2670 public void onLongPress(MotionEvent event) { 2671 if (mSelectedChip != null) { 2672 return; 2673 } 2674 float x = event.getX(); 2675 float y = event.getY(); 2676 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 2677 DrawableRecipientChip currentChip = findChip(offset); 2678 if (currentChip != null) { 2679 if (mDragEnabled) { 2680 // Start drag-and-drop for the selected chip. 2681 startDrag(currentChip); 2682 } else { 2683 // Copy the selected chip email address. 2684 showCopyDialog(currentChip.getEntry().getDestination()); 2685 } 2686 } 2687 } 2688 2689 /** 2690 * Enables drag-and-drop for chips. 2691 */ 2692 public void enableDrag() { 2693 mDragEnabled = true; 2694 } 2695 2696 /** 2697 * Starts drag-and-drop for the selected chip. 2698 */ 2699 private void startDrag(DrawableRecipientChip currentChip) { 2700 String address = currentChip.getEntry().getDestination(); 2701 ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA); 2702 2703 // Start drag mode. 2704 startDrag(data, new RecipientChipShadow(currentChip), null, 0); 2705 2706 // Remove the current chip, so drag-and-drop will result in a move. 2707 // TODO (phamm): consider readd this chip if it's dropped outside a target. 2708 removeChip(currentChip); 2709 } 2710 2711 /** 2712 * Handles drag event. 2713 */ 2714 @Override 2715 public boolean onDragEvent(DragEvent event) { 2716 switch (event.getAction()) { 2717 case DragEvent.ACTION_DRAG_STARTED: 2718 // Only handle plain text drag and drop. 2719 return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); 2720 case DragEvent.ACTION_DRAG_ENTERED: 2721 requestFocus(); 2722 return true; 2723 case DragEvent.ACTION_DROP: 2724 handlePasteClip(event.getClipData()); 2725 return true; 2726 } 2727 return false; 2728 } 2729 2730 /** 2731 * Drag shadow for a {@link RecipientChip}. 2732 */ 2733 private final class RecipientChipShadow extends DragShadowBuilder { 2734 private final DrawableRecipientChip mChip; 2735 2736 public RecipientChipShadow(DrawableRecipientChip chip) { 2737 mChip = chip; 2738 } 2739 2740 @Override 2741 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 2742 Rect rect = mChip.getBounds(); 2743 shadowSize.set(rect.width(), rect.height()); 2744 shadowTouchPoint.set(rect.centerX(), rect.centerY()); 2745 } 2746 2747 @Override 2748 public void onDrawShadow(Canvas canvas) { 2749 mChip.draw(canvas); 2750 } 2751 } 2752 2753 private void showCopyDialog(final String address) { 2754 mCopyAddress = address; 2755 mCopyDialog.setTitle(address); 2756 mCopyDialog.setContentView(R.layout.copy_chip_dialog_layout); 2757 mCopyDialog.setCancelable(true); 2758 mCopyDialog.setCanceledOnTouchOutside(true); 2759 Button button = (Button)mCopyDialog.findViewById(android.R.id.button1); 2760 button.setOnClickListener(this); 2761 int btnTitleId; 2762 if (isPhoneQuery()) { 2763 btnTitleId = R.string.copy_number; 2764 } else { 2765 btnTitleId = R.string.copy_email; 2766 } 2767 String buttonTitle = getContext().getResources().getString(btnTitleId); 2768 button.setText(buttonTitle); 2769 mCopyDialog.setOnDismissListener(this); 2770 mCopyDialog.show(); 2771 } 2772 2773 @Override 2774 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { 2775 // Do nothing. 2776 return false; 2777 } 2778 2779 @Override 2780 public void onShowPress(MotionEvent e) { 2781 // Do nothing. 2782 } 2783 2784 @Override 2785 public boolean onSingleTapUp(MotionEvent e) { 2786 // Do nothing. 2787 return false; 2788 } 2789 2790 @Override 2791 public void onDismiss(DialogInterface dialog) { 2792 mCopyAddress = null; 2793 } 2794 2795 @Override 2796 public void onClick(View v) { 2797 // Copy this to the clipboard. 2798 ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService( 2799 Context.CLIPBOARD_SERVICE); 2800 clipboard.setPrimaryClip(ClipData.newPlainText("", mCopyAddress)); 2801 mCopyDialog.dismiss(); 2802 } 2803 2804 protected boolean isPhoneQuery() { 2805 return getAdapter() != null 2806 && ((BaseRecipientAdapter) getAdapter()).getQueryType() 2807 == BaseRecipientAdapter.QUERY_TYPE_PHONE; 2808 } 2809} 2810