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 18 package com.android.ex.chips; 19 20 import android.annotation.TargetApi; 21 import android.app.Activity; 22 import android.app.DialogFragment; 23 import android.content.ClipData; 24 import android.content.ClipDescription; 25 import android.content.ClipboardManager; 26 import android.content.Context; 27 import android.content.res.Resources; 28 import android.content.res.TypedArray; 29 import android.graphics.Bitmap; 30 import android.graphics.BitmapFactory; 31 import android.graphics.BitmapShader; 32 import android.graphics.Canvas; 33 import android.graphics.Color; 34 import android.graphics.Matrix; 35 import android.graphics.Paint; 36 import android.graphics.Paint.Style; 37 import android.graphics.Point; 38 import android.graphics.Rect; 39 import android.graphics.RectF; 40 import android.graphics.Shader.TileMode; 41 import android.graphics.drawable.BitmapDrawable; 42 import android.graphics.drawable.Drawable; 43 import android.graphics.drawable.StateListDrawable; 44 import android.os.AsyncTask; 45 import android.os.Build; 46 import android.os.Handler; 47 import android.os.Looper; 48 import android.os.Message; 49 import android.os.Parcelable; 50 import android.support.annotation.NonNull; 51 import android.text.Editable; 52 import android.text.InputType; 53 import android.text.Layout; 54 import android.text.Spannable; 55 import android.text.SpannableString; 56 import android.text.SpannableStringBuilder; 57 import android.text.Spanned; 58 import android.text.TextPaint; 59 import android.text.TextUtils; 60 import android.text.TextWatcher; 61 import android.text.method.QwertyKeyListener; 62 import android.text.util.Rfc822Token; 63 import android.text.util.Rfc822Tokenizer; 64 import android.util.AttributeSet; 65 import android.util.Log; 66 import android.view.ActionMode; 67 import android.view.ActionMode.Callback; 68 import android.view.DragEvent; 69 import android.view.GestureDetector; 70 import android.view.KeyEvent; 71 import android.view.LayoutInflater; 72 import android.view.Menu; 73 import android.view.MenuItem; 74 import android.view.MotionEvent; 75 import android.view.View; 76 import android.view.ViewParent; 77 import android.view.accessibility.AccessibilityEvent; 78 import android.view.accessibility.AccessibilityManager; 79 import android.view.inputmethod.EditorInfo; 80 import android.view.inputmethod.InputConnection; 81 import android.widget.AdapterView; 82 import android.widget.AdapterView.OnItemClickListener; 83 import android.widget.Filterable; 84 import android.widget.ListAdapter; 85 import android.widget.ListPopupWindow; 86 import android.widget.ListView; 87 import android.widget.MultiAutoCompleteTextView; 88 import android.widget.PopupWindow; 89 import android.widget.ScrollView; 90 import android.widget.TextView; 91 92 import com.android.ex.chips.DropdownChipLayouter.PermissionRequestDismissedListener; 93 import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; 94 import com.android.ex.chips.recipientchip.DrawableRecipientChip; 95 import com.android.ex.chips.recipientchip.InvisibleRecipientChip; 96 import com.android.ex.chips.recipientchip.ReplacementDrawableSpan; 97 import com.android.ex.chips.recipientchip.VisibleRecipientChip; 98 99 import java.util.ArrayList; 100 import java.util.Arrays; 101 import java.util.Collections; 102 import java.util.Comparator; 103 import java.util.List; 104 import java.util.Map; 105 import java.util.Set; 106 107 /** 108 * RecipientEditTextView is an auto complete text view for use with applications 109 * that use the new Chips UI for addressing a message to recipients. 110 */ 111 public class RecipientEditTextView extends MultiAutoCompleteTextView implements 112 OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener, 113 GestureDetector.OnGestureListener, TextView.OnEditorActionListener, 114 DropdownChipLayouter.ChipDeleteListener, PermissionRequestDismissedListener { 115 private static final String TAG = "RecipientEditTextView"; 116 117 private static final char COMMIT_CHAR_COMMA = ','; 118 private static final char COMMIT_CHAR_SEMICOLON = ';'; 119 private static final char COMMIT_CHAR_SPACE = ' '; 120 private static final String SEPARATOR = String.valueOf(COMMIT_CHAR_COMMA) 121 + String.valueOf(COMMIT_CHAR_SPACE); 122 123 private static final int DISMISS = "dismiss".hashCode(); 124 private static final long DISMISS_DELAY = 300; 125 126 // TODO: get correct number/ algorithm from with UX. 127 // Visible for testing. 128 /*package*/ static final int CHIP_LIMIT = 2; 129 130 private static final int MAX_CHIPS_PARSED = 50; 131 132 private int mUnselectedChipTextColor; 133 private int mUnselectedChipBackgroundColor; 134 135 // Work variables to avoid re-allocation on every typed character. 136 private final Rect mRect = new Rect(); 137 private final int[] mCoords = new int[2]; 138 139 // Resources for displaying chips. 140 private Drawable mChipBackground = null; 141 private Drawable mChipDelete = null; 142 private Drawable mInvalidChipBackground; 143 144 // Possible attr overrides 145 private float mChipHeight; 146 private float mChipFontSize; 147 private float mLineSpacingExtra; 148 private int mChipTextStartPadding; 149 private int mChipTextEndPadding; 150 private final int mTextHeight; 151 private boolean mDisableDelete; 152 private int mMaxLines; 153 154 /** 155 * Enumerator for avatar position. See attr.xml for more details. 156 * 0 for end, 1 for start. 157 */ 158 private int mAvatarPosition; 159 private static final int AVATAR_POSITION_END = 0; 160 private static final int AVATAR_POSITION_START = 1; 161 162 private Paint mWorkPaint = new Paint(); 163 164 private Tokenizer mTokenizer; 165 private Validator mValidator; 166 private Handler mHandler; 167 private TextWatcher mTextWatcher; 168 protected DropdownChipLayouter mDropdownChipLayouter; 169 170 private View mDropdownAnchor = this; 171 private ListPopupWindow mAlternatesPopup; 172 private ListPopupWindow mAddressPopup; 173 private View mAlternatePopupAnchor; 174 private OnItemClickListener mAlternatesListener; 175 176 private DrawableRecipientChip mSelectedChip; 177 private Bitmap mDefaultContactPhoto; 178 private ReplacementDrawableSpan mMoreChip; 179 private TextView mMoreItem; 180 181 private int mCurrentSuggestionCount; 182 183 // VisibleForTesting 184 final ArrayList<String> mPendingChips = new ArrayList<String>(); 185 186 private int mPendingChipsCount = 0; 187 private int mCheckedItem; 188 private boolean mNoChipMode = false; 189 private boolean mShouldShrink = true; 190 private boolean mRequiresShrinkWhenNotGone = false; 191 192 // VisibleForTesting 193 ArrayList<DrawableRecipientChip> mTemporaryRecipients; 194 195 private ArrayList<DrawableRecipientChip> mHiddenSpans; 196 197 // Chip copy fields. 198 private GestureDetector mGestureDetector; 199 200 // Obtain the enclosing scroll view, if it exists, so that the view can be 201 // scrolled to show the last line of chips content. 202 private ScrollView mScrollView; 203 private boolean mTriedGettingScrollView; 204 private boolean mDragEnabled = false; 205 206 private boolean mAttachedToWindow; 207 208 private final Runnable mAddTextWatcher = new Runnable() { 209 @Override 210 public void run() { 211 if (mTextWatcher == null) { 212 mTextWatcher = new RecipientTextWatcher(); 213 addTextChangedListener(mTextWatcher); 214 } 215 } 216 }; 217 218 private IndividualReplacementTask mIndividualReplacements; 219 220 private Runnable mHandlePendingChips = new Runnable() { 221 222 @Override 223 public void run() { 224 handlePendingChips(); 225 } 226 227 }; 228 229 private Runnable mDelayedShrink = new Runnable() { 230 231 @Override 232 public void run() { 233 shrink(); 234 } 235 236 }; 237 238 private RecipientEntryItemClickedListener mRecipientEntryItemClickedListener; 239 240 private RecipientChipAddedListener mRecipientChipAddedListener; 241 private RecipientChipDeletedListener mRecipientChipDeletedListener; 242 243 public interface RecipientEntryItemClickedListener { 244 /** 245 * Callback that occurs whenever an auto-complete suggestion is clicked. 246 * @param charactersTyped the number of characters typed by the user to provide the 247 * auto-complete suggestions. 248 * @param position the position in the dropdown list that the user clicked 249 */ onRecipientEntryItemClicked(int charactersTyped, int position)250 void onRecipientEntryItemClicked(int charactersTyped, int position); 251 } 252 253 private PermissionsRequestItemClickedListener mPermissionsRequestItemClickedListener; 254 255 /** 256 * Listener for handling clicks on the {@link RecipientEntry} that have 257 * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type. 258 */ 259 public interface PermissionsRequestItemClickedListener { 260 261 /** 262 * Callback that occurs when user clicks the item that asks user to grant permissions to 263 * the app. 264 * 265 * @param view View that asks for permission. 266 */ onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions)267 void onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions); 268 269 /** 270 * Callback that occurs when user dismisses the item that asks user to grant permissions to 271 * the app. 272 */ onPermissionRequestDismissed()273 void onPermissionRequestDismissed(); 274 } 275 276 /** 277 * Listener for handling deletion of chips in the recipient edit text. 278 */ 279 public interface RecipientChipDeletedListener { 280 /** 281 * Callback that occurs when a chip is deleted. 282 * @param entry RecipientEntry that contains information about the chip. 283 */ onRecipientChipDeleted(RecipientEntry entry)284 void onRecipientChipDeleted(RecipientEntry entry); 285 } 286 287 /** 288 * Listener for handling addition of chips in the recipient edit text. 289 */ 290 public interface RecipientChipAddedListener { 291 /** 292 * Callback that occurs when a chip is added. 293 * 294 * @param entry RecipientEntry that contains information about the chip. 295 */ onRecipientChipAdded(RecipientEntry entry)296 void onRecipientChipAdded(RecipientEntry entry); 297 } 298 RecipientEditTextView(Context context, AttributeSet attrs)299 public RecipientEditTextView(Context context, AttributeSet attrs) { 300 super(context, attrs); 301 setChipDimensions(context, attrs); 302 mTextHeight = calculateTextHeight(); 303 mAlternatesPopup = new ListPopupWindow(context); 304 setupPopupWindow(mAlternatesPopup); 305 mAddressPopup = new ListPopupWindow(context); 306 setupPopupWindow(mAddressPopup); 307 mAlternatesListener = new OnItemClickListener() { 308 @Override 309 public void onItemClick(AdapterView<?> adapterView,View view, int position, 310 long rowId) { 311 mAlternatesPopup.setOnItemClickListener(null); 312 replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter()) 313 .getRecipientEntry(position)); 314 Message delayed = Message.obtain(mHandler, DISMISS); 315 delayed.obj = mAlternatesPopup; 316 mHandler.sendMessageDelayed(delayed, DISMISS_DELAY); 317 clearComposingText(); 318 } 319 }; 320 setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); 321 setOnItemClickListener(this); 322 setCustomSelectionActionModeCallback(this); 323 mHandler = new Handler() { 324 @Override 325 public void handleMessage(Message msg) { 326 if (msg.what == DISMISS) { 327 ((ListPopupWindow) msg.obj).dismiss(); 328 return; 329 } 330 super.handleMessage(msg); 331 } 332 }; 333 mTextWatcher = new RecipientTextWatcher(); 334 addTextChangedListener(mTextWatcher); 335 mGestureDetector = new GestureDetector(context, this); 336 setOnEditorActionListener(this); 337 338 setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context)); 339 } 340 setupPopupWindow(ListPopupWindow popup)341 private void setupPopupWindow(ListPopupWindow popup) { 342 popup.setOnDismissListener(new PopupWindow.OnDismissListener() { 343 @Override 344 public void onDismiss() { 345 clearSelectedChip(); 346 } 347 }); 348 } 349 calculateTextHeight()350 private int calculateTextHeight() { 351 final TextPaint paint = getPaint(); 352 353 mRect.setEmpty(); 354 // First measure the bounds of a sample text. 355 final String textHeightSample = "a"; 356 paint.getTextBounds(textHeightSample, 0, textHeightSample.length(), mRect); 357 358 mRect.left = 0; 359 mRect.right = 0; 360 361 return mRect.height(); 362 } 363 setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter)364 public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) { 365 mDropdownChipLayouter = dropdownChipLayouter; 366 mDropdownChipLayouter.setDeleteListener(this); 367 mDropdownChipLayouter.setPermissionRequestDismissedListener(this); 368 } 369 setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener)370 public void setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener) { 371 mRecipientEntryItemClickedListener = listener; 372 } 373 setPermissionsRequestItemClickedListener( PermissionsRequestItemClickedListener listener)374 public void setPermissionsRequestItemClickedListener( 375 PermissionsRequestItemClickedListener listener) { 376 mPermissionsRequestItemClickedListener = listener; 377 } 378 setRecipientChipAddedListener(RecipientChipAddedListener listener)379 public void setRecipientChipAddedListener(RecipientChipAddedListener listener) { 380 mRecipientChipAddedListener = listener; 381 } 382 setRecipientChipDeletedListener(RecipientChipDeletedListener listener)383 public void setRecipientChipDeletedListener(RecipientChipDeletedListener listener) { 384 mRecipientChipDeletedListener = listener; 385 } 386 387 @Override onDetachedFromWindow()388 protected void onDetachedFromWindow() { 389 super.onDetachedFromWindow(); 390 mAttachedToWindow = false; 391 } 392 393 @Override onAttachedToWindow()394 protected void onAttachedToWindow() { 395 super.onAttachedToWindow(); 396 mAttachedToWindow = true; 397 398 final int anchorId = getDropDownAnchor(); 399 if (anchorId != View.NO_ID) { 400 mDropdownAnchor = getRootView().findViewById(anchorId); 401 } 402 } 403 404 @Override setDropDownAnchor(int anchorId)405 public void setDropDownAnchor(int anchorId) { 406 super.setDropDownAnchor(anchorId); 407 if (anchorId != View.NO_ID) { 408 mDropdownAnchor = getRootView().findViewById(anchorId); 409 } 410 } 411 412 @Override onEditorAction(TextView view, int action, KeyEvent keyEvent)413 public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { 414 if (action == EditorInfo.IME_ACTION_DONE) { 415 if (commitDefault()) { 416 return true; 417 } 418 if (mSelectedChip != null) { 419 clearSelectedChip(); 420 return true; 421 } else if (hasFocus()) { 422 if (focusNext()) { 423 return true; 424 } 425 } 426 } 427 return false; 428 } 429 430 @Override onCreateInputConnection(@onNull EditorInfo outAttrs)431 public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { 432 InputConnection connection = super.onCreateInputConnection(outAttrs); 433 int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION; 434 if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) { 435 // clear the existing action 436 outAttrs.imeOptions ^= imeActions; 437 // set the DONE action 438 outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE; 439 } 440 if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { 441 outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; 442 } 443 444 outAttrs.actionId = EditorInfo.IME_ACTION_DONE; 445 446 // Custom action labels are discouraged in L; a checkmark icon is shown in place of the 447 // custom text in this case. 448 outAttrs.actionLabel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? null : 449 getContext().getString(R.string.action_label); 450 return connection; 451 } 452 getLastChip()453 /*package*/ DrawableRecipientChip getLastChip() { 454 DrawableRecipientChip last = null; 455 DrawableRecipientChip[] chips = getSortedRecipients(); 456 if (chips != null && chips.length > 0) { 457 last = chips[chips.length - 1]; 458 } 459 return last; 460 } 461 462 /** 463 * @return The list of {@link RecipientEntry}s that have been selected by the user. 464 */ getSelectedRecipients()465 public List<RecipientEntry> getSelectedRecipients() { 466 DrawableRecipientChip[] chips = 467 getText().getSpans(0, getText().length(), DrawableRecipientChip.class); 468 List<RecipientEntry> results = new ArrayList<RecipientEntry>(); 469 if (chips == null) { 470 return results; 471 } 472 473 for (DrawableRecipientChip c : chips) { 474 results.add(c.getEntry()); 475 } 476 477 return results; 478 } 479 480 /** 481 * @return The list of {@link RecipientEntry}s that have been selected by the user and also 482 * hidden due to {@link #mMoreChip} span. 483 */ getAllRecipients()484 public List<RecipientEntry> getAllRecipients() { 485 List<RecipientEntry> results = getSelectedRecipients(); 486 487 if (mHiddenSpans != null) { 488 for (DrawableRecipientChip chip : mHiddenSpans) { 489 results.add(chip.getEntry()); 490 } 491 } 492 493 return results; 494 } 495 496 @Override onSelectionChanged(int start, int end)497 public void onSelectionChanged(int start, int end) { 498 // When selection changes, see if it is inside the chips area. 499 // If so, move the cursor back after the chips again. 500 // Only exception is when we change the selection due to a selected chip. 501 DrawableRecipientChip last = getLastChip(); 502 if (mSelectedChip == null && last != null && start < getSpannable().getSpanEnd(last)) { 503 // Grab the last chip and set the cursor to after it. 504 setSelection(Math.min(getSpannable().getSpanEnd(last) + 1, getText().length())); 505 } 506 super.onSelectionChanged(start, end); 507 } 508 509 @Override onRestoreInstanceState(Parcelable state)510 public void onRestoreInstanceState(Parcelable state) { 511 if (!TextUtils.isEmpty(getText())) { 512 super.onRestoreInstanceState(null); 513 } else { 514 super.onRestoreInstanceState(state); 515 } 516 } 517 518 @Override onSaveInstanceState()519 public Parcelable onSaveInstanceState() { 520 // If the user changes orientation while they are editing, just roll back the selection. 521 clearSelectedChip(); 522 return super.onSaveInstanceState(); 523 } 524 525 /** 526 * Convenience method: Append the specified text slice to the TextView's 527 * display buffer, upgrading it to BufferType.EDITABLE if it was 528 * not already editable. Commas are excluded as they are added automatically 529 * by the view. 530 */ 531 @Override append(CharSequence text, int start, int end)532 public void append(CharSequence text, int start, int end) { 533 // We don't care about watching text changes while appending. 534 if (mTextWatcher != null) { 535 removeTextChangedListener(mTextWatcher); 536 } 537 super.append(text, start, end); 538 if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) { 539 String displayString = text.toString(); 540 541 if (!displayString.trim().endsWith(String.valueOf(COMMIT_CHAR_COMMA))) { 542 // We have no separator, so we should add it 543 super.append(SEPARATOR, 0, SEPARATOR.length()); 544 displayString += SEPARATOR; 545 } 546 547 if (!TextUtils.isEmpty(displayString) 548 && TextUtils.getTrimmedLength(displayString) > 0) { 549 mPendingChipsCount++; 550 mPendingChips.add(displayString); 551 } 552 } 553 // Put a message on the queue to make sure we ALWAYS handle pending 554 // chips. 555 if (mPendingChipsCount > 0) { 556 postHandlePendingChips(); 557 } 558 mHandler.post(mAddTextWatcher); 559 } 560 561 @Override onFocusChanged(boolean hasFocus, int direction, Rect previous)562 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 563 super.onFocusChanged(hasFocus, direction, previous); 564 if (!hasFocus) { 565 shrink(); 566 } else { 567 expand(); 568 } 569 } 570 571 @Override setAdapter(@onNull T adapter)572 public <T extends ListAdapter & Filterable> void setAdapter(@NonNull T adapter) { 573 super.setAdapter(adapter); 574 BaseRecipientAdapter baseAdapter = (BaseRecipientAdapter) adapter; 575 baseAdapter.registerUpdateObserver(new BaseRecipientAdapter.EntriesUpdatedObserver() { 576 @Override 577 public void onChanged(List<RecipientEntry> entries) { 578 int suggestionCount = entries == null ? 0 : entries.size(); 579 580 // Scroll the chips field to the top of the screen so 581 // that the user can see as many results as possible. 582 if (entries != null && entries.size() > 0) { 583 scrollBottomIntoView(); 584 // Here the current suggestion count is still the old one since we update 585 // the count at the bottom of this function. 586 if (mCurrentSuggestionCount == 0) { 587 // Announce the new number of possible choices for accessibility. 588 announceForAccessibilityCompat( 589 getSuggestionDropdownOpenedVerbalization(suggestionCount)); 590 } 591 } 592 593 // Is the dropdown closing? 594 if ((entries == null || entries.size() == 0) 595 // Here the current suggestion count is still the old one since we update 596 // the count at the bottom of this function. 597 && mCurrentSuggestionCount != 0 598 // If there is no text, there's no need to know if no suggestions are 599 // available. 600 && getText().length() > 0) { 601 announceForAccessibilityCompat(getResources().getString( 602 R.string.accessbility_suggestion_dropdown_closed)); 603 } 604 605 if ((entries != null) 606 && (entries.size() == 1) 607 && (entries.get(0).getEntryType() == 608 RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST)) { 609 // Do nothing; showing a single permissions entry. Resizing not required. 610 } else { 611 // Set the dropdown height to be the remaining height from the anchor to the 612 // bottom. 613 mDropdownAnchor.getLocationOnScreen(mCoords); 614 getWindowVisibleDisplayFrame(mRect); 615 setDropDownHeight(mRect.bottom - mCoords[1] - mDropdownAnchor.getHeight() - 616 getDropDownVerticalOffset()); 617 } 618 619 mCurrentSuggestionCount = suggestionCount; 620 } 621 }); 622 baseAdapter.setDropdownChipLayouter(mDropdownChipLayouter); 623 } 624 625 /** 626 * Return the accessibility verbalization when the suggestion dropdown is opened. 627 */ getSuggestionDropdownOpenedVerbalization(int suggestionCount)628 public String getSuggestionDropdownOpenedVerbalization(int suggestionCount) { 629 return getResources().getString(R.string.accessbility_suggestion_dropdown_opened); 630 } 631 632 @TargetApi(Build.VERSION_CODES.JELLY_BEAN) announceForAccessibilityCompat(String text)633 private void announceForAccessibilityCompat(String text) { 634 final AccessibilityManager accessibilityManager = 635 (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); 636 final boolean isAccessibilityOn = accessibilityManager.isEnabled(); 637 638 if (isAccessibilityOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 639 final ViewParent parent = getParent(); 640 if (parent != null) { 641 AccessibilityEvent event = AccessibilityEvent.obtain( 642 AccessibilityEvent.TYPE_ANNOUNCEMENT); 643 onInitializeAccessibilityEvent(event); 644 event.getText().add(text); 645 event.setContentDescription(null); 646 parent.requestSendAccessibilityEvent(this, event); 647 } 648 } 649 } 650 scrollBottomIntoView()651 protected void scrollBottomIntoView() { 652 if (mScrollView != null && mShouldShrink) { 653 getLocationInWindow(mCoords); 654 // Desired position shows at least 1 line of chips below the action 655 // bar. We add excess padding to make sure this is always below other 656 // content. 657 final int height = getHeight(); 658 final int currentPos = mCoords[1] + height; 659 mScrollView.getLocationInWindow(mCoords); 660 final int desiredPos = mCoords[1] + height / getLineCount(); 661 if (currentPos > desiredPos) { 662 mScrollView.scrollBy(0, currentPos - desiredPos); 663 } 664 } 665 } 666 getScrollView()667 protected ScrollView getScrollView() { 668 return mScrollView; 669 } 670 671 @Override performValidation()672 public void performValidation() { 673 // Do nothing. Chips handles its own validation. 674 } 675 shrink()676 private void shrink() { 677 if (mTokenizer == null) { 678 return; 679 } 680 long contactId = mSelectedChip != null ? mSelectedChip.getEntry().getContactId() : -1; 681 if (mSelectedChip != null && contactId != RecipientEntry.INVALID_CONTACT 682 && (!isPhoneQuery() && contactId != RecipientEntry.GENERATED_CONTACT)) { 683 clearSelectedChip(); 684 } else { 685 if (getWidth() <= 0) { 686 mHandler.removeCallbacks(mDelayedShrink); 687 688 if (getVisibility() == GONE) { 689 // We aren't going to have a width any time soon, so defer 690 // this until we're not GONE. 691 mRequiresShrinkWhenNotGone = true; 692 } else { 693 // We don't have the width yet which means the view hasn't been drawn yet 694 // and there is no reason to attempt to commit chips yet. 695 // This focus lost must be the result of an orientation change 696 // or an initial rendering. 697 // Re-post the shrink for later. 698 mHandler.post(mDelayedShrink); 699 } 700 return; 701 } 702 // Reset any pending chips as they would have been handled 703 // when the field lost focus. 704 if (mPendingChipsCount > 0) { 705 postHandlePendingChips(); 706 } else { 707 Editable editable = getText(); 708 int end = getSelectionEnd(); 709 int start = mTokenizer.findTokenStart(editable, end); 710 DrawableRecipientChip[] chips = 711 getSpannable().getSpans(start, end, DrawableRecipientChip.class); 712 if ((chips == null || chips.length == 0)) { 713 Editable text = getText(); 714 int whatEnd = mTokenizer.findTokenEnd(text, start); 715 // This token was already tokenized, so skip past the ending token. 716 if (whatEnd < text.length() && text.charAt(whatEnd) == ',') { 717 whatEnd = movePastTerminators(whatEnd); 718 } 719 // In the middle of chip; treat this as an edit 720 // and commit the whole token. 721 int selEnd = getSelectionEnd(); 722 if (whatEnd != selEnd) { 723 handleEdit(start, whatEnd); 724 } else { 725 commitChip(start, end, editable); 726 } 727 } 728 } 729 mHandler.post(mAddTextWatcher); 730 } 731 createMoreChip(); 732 } 733 expand()734 private void expand() { 735 if (mShouldShrink) { 736 setMaxLines(Integer.MAX_VALUE); 737 } 738 removeMoreChip(); 739 setCursorVisible(true); 740 Editable text = getText(); 741 setSelection(text != null && text.length() > 0 ? text.length() : 0); 742 // If there are any temporary chips, try replacing them now that the user 743 // has expanded the field. 744 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) { 745 new RecipientReplacementTask().execute(); 746 mTemporaryRecipients = null; 747 } 748 } 749 ellipsizeText(CharSequence text, TextPaint paint, float maxWidth)750 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 751 paint.setTextSize(mChipFontSize); 752 if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) { 753 Log.d(TAG, "Max width is negative: " + maxWidth); 754 } 755 return TextUtils.ellipsize(text, paint, maxWidth, 756 TextUtils.TruncateAt.END); 757 } 758 759 /** 760 * Creates a bitmap of the given contact on a selected chip. 761 * 762 * @param contact The recipient entry to pull data from. 763 * @param paint The paint to use to draw the bitmap. 764 */ createChipBitmap(RecipientEntry contact, TextPaint paint)765 private Bitmap createChipBitmap(RecipientEntry contact, TextPaint paint) { 766 paint.setColor(getDefaultChipTextColor(contact)); 767 ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint, 768 getChipBackground(contact), getDefaultChipBackgroundColor(contact)); 769 770 if (bitmapContainer.loadIcon) { 771 loadAvatarIcon(contact, bitmapContainer); 772 } 773 return bitmapContainer.bitmap; 774 } 775 createChipBitmap(RecipientEntry contact, TextPaint paint, Drawable overrideBackgroundDrawable, int backgroundColor)776 private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint, 777 Drawable overrideBackgroundDrawable, int backgroundColor) { 778 final ChipBitmapContainer result = new ChipBitmapContainer(); 779 780 Drawable indicatorIcon = null; 781 int indicatorPadding = 0; 782 if (contact.getIndicatorIconId() != 0) { 783 indicatorIcon = getContext().getDrawable(contact.getIndicatorIconId()); 784 indicatorIcon.setBounds(0, 0, 785 indicatorIcon.getIntrinsicWidth(), indicatorIcon.getIntrinsicHeight()); 786 indicatorPadding = indicatorIcon.getBounds().width() + mChipTextEndPadding; 787 } 788 789 Rect backgroundPadding = new Rect(); 790 if (overrideBackgroundDrawable != null) { 791 overrideBackgroundDrawable.getPadding(backgroundPadding); 792 } 793 794 // Ellipsize the text so that it takes AT MOST the entire width of the 795 // autocomplete text entry area. Make sure to leave space for padding 796 // on the sides. 797 int height = (int) mChipHeight; 798 // Since the icon is a square, it's width is equal to the maximum height it can be inside 799 // the chip. Don't include iconWidth for invalid contacts. 800 int iconWidth = contact.isValid() ? 801 height - backgroundPadding.top - backgroundPadding.bottom : 0; 802 float[] widths = new float[1]; 803 paint.getTextWidths(" ", widths); 804 CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint, 805 calculateAvailableWidth() - iconWidth - widths[0] - backgroundPadding.left 806 - backgroundPadding.right - indicatorPadding); 807 int textWidth = (int) paint.measureText(ellipsizedText, 0, ellipsizedText.length()); 808 809 // Chip start padding is the same as the end padding if there is no contact image. 810 final int startPadding = contact.isValid() ? mChipTextStartPadding : mChipTextEndPadding; 811 // Make sure there is a minimum chip width so the user can ALWAYS 812 // tap a chip without difficulty. 813 int width = Math.max(iconWidth * 2, textWidth + startPadding + mChipTextEndPadding 814 + iconWidth + backgroundPadding.left + backgroundPadding.right + indicatorPadding); 815 816 // Create the background of the chip. 817 result.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 818 final Canvas canvas = new Canvas(result.bitmap); 819 820 // Check if the background drawable is set via attr 821 if (overrideBackgroundDrawable != null) { 822 overrideBackgroundDrawable.setBounds(0, 0, width, height); 823 overrideBackgroundDrawable.draw(canvas); 824 } else { 825 // Draw the default chip background 826 mWorkPaint.reset(); 827 mWorkPaint.setColor(backgroundColor); 828 final float radius = height / 2; 829 canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius, 830 mWorkPaint); 831 } 832 833 // Draw the text vertically aligned 834 int textX = shouldPositionAvatarOnRight() ? 835 mChipTextEndPadding + backgroundPadding.left + indicatorPadding : 836 width - backgroundPadding.right - mChipTextEndPadding - textWidth - 837 indicatorPadding; 838 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), 839 textX, getTextYOffset(height), paint); 840 841 if (indicatorIcon != null) { 842 int indicatorX = shouldPositionAvatarOnRight() 843 ? backgroundPadding.left + mChipTextEndPadding 844 : width - backgroundPadding.right - indicatorIcon.getBounds().width() 845 - mChipTextEndPadding; 846 int indicatorY = height / 2 - indicatorIcon.getBounds().height() / 2; 847 indicatorIcon.getBounds().offsetTo(indicatorX, indicatorY); 848 indicatorIcon.draw(canvas); 849 } 850 851 // Set the variables that are needed to draw the icon bitmap once it's loaded 852 int iconX = shouldPositionAvatarOnRight() ? width - backgroundPadding.right - iconWidth : 853 backgroundPadding.left; 854 result.left = iconX; 855 result.top = backgroundPadding.top; 856 result.right = iconX + iconWidth; 857 result.bottom = height - backgroundPadding.bottom; 858 859 return result; 860 } 861 862 /** 863 * Helper function that draws the loaded icon bitmap into the chips bitmap 864 */ drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon)865 private void drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon) { 866 final Canvas canvas = new Canvas(bitMapResult.bitmap); 867 final RectF src = new RectF(0, 0, icon.getWidth(), icon.getHeight()); 868 final RectF dst = new RectF(bitMapResult.left, bitMapResult.top, bitMapResult.right, 869 bitMapResult.bottom); 870 drawIconOnCanvas(icon, canvas, src, dst); 871 } 872 873 /** 874 * Returns true if the avatar should be positioned at the right edge of the chip. 875 * Takes into account both the set avatar position (start or end) as well as whether 876 * the layout direction is LTR or RTL. 877 */ shouldPositionAvatarOnRight()878 private boolean shouldPositionAvatarOnRight() { 879 final boolean isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && 880 getLayoutDirection() == LAYOUT_DIRECTION_RTL; 881 final boolean assignedPosition = mAvatarPosition == AVATAR_POSITION_END; 882 // If in Rtl mode, the position should be flipped. 883 return isRtl ? !assignedPosition : assignedPosition; 884 } 885 886 /** 887 * Returns the avatar icon to use for this recipient entry. Returns null if we don't want to 888 * draw an icon for this recipient. 889 */ loadAvatarIcon(final RecipientEntry contact, final ChipBitmapContainer bitmapContainer)890 private void loadAvatarIcon(final RecipientEntry contact, 891 final ChipBitmapContainer bitmapContainer) { 892 // Don't draw photos for recipients that have been typed in OR generated on the fly. 893 long contactId = contact.getContactId(); 894 boolean drawPhotos = isPhoneQuery() ? 895 contactId != RecipientEntry.INVALID_CONTACT 896 : (contactId != RecipientEntry.INVALID_CONTACT 897 && contactId != RecipientEntry.GENERATED_CONTACT); 898 899 if (drawPhotos) { 900 final byte[] origPhotoBytes = contact.getPhotoBytes(); 901 // There may not be a photo yet if anything but the first contact address 902 // was selected. 903 if (origPhotoBytes == null) { 904 // TODO: cache this in the recipient entry? 905 getAdapter().fetchPhoto(contact, new PhotoManager.PhotoManagerCallback() { 906 @Override 907 public void onPhotoBytesPopulated() { 908 // Call through to the async version which will ensure 909 // proper threading. 910 onPhotoBytesAsynchronouslyPopulated(); 911 } 912 913 @Override 914 public void onPhotoBytesAsynchronouslyPopulated() { 915 final byte[] loadedPhotoBytes = contact.getPhotoBytes(); 916 final Bitmap icon = BitmapFactory.decodeByteArray(loadedPhotoBytes, 0, 917 loadedPhotoBytes.length); 918 tryDrawAndInvalidate(icon); 919 } 920 921 @Override 922 public void onPhotoBytesAsyncLoadFailed() { 923 // TODO: can the scaled down default photo be cached? 924 tryDrawAndInvalidate(mDefaultContactPhoto); 925 } 926 927 private void tryDrawAndInvalidate(Bitmap icon) { 928 drawIcon(bitmapContainer, icon); 929 // The caller might originated from a background task. However, if the 930 // background task has already completed, the view might be already drawn 931 // on the UI but the callback would happen on the background thread. 932 // So if we are on a background thread, post an invalidate call to the UI. 933 if (Looper.myLooper() == Looper.getMainLooper()) { 934 // The view might not redraw itself since it's loaded asynchronously 935 invalidate(); 936 } else { 937 post(new Runnable() { 938 @Override 939 public void run() { 940 invalidate(); 941 } 942 }); 943 } 944 } 945 }); 946 } else { 947 final Bitmap icon = BitmapFactory.decodeByteArray(origPhotoBytes, 0, 948 origPhotoBytes.length); 949 drawIcon(bitmapContainer, icon); 950 } 951 } 952 } 953 954 /** 955 * Get the background drawable for a RecipientChip. 956 */ 957 // Visible for testing. getChipBackground(RecipientEntry contact)958 /* package */Drawable getChipBackground(RecipientEntry contact) { 959 return contact.isValid() ? mChipBackground : mInvalidChipBackground; 960 } 961 getDefaultChipTextColor(RecipientEntry contact)962 private int getDefaultChipTextColor(RecipientEntry contact) { 963 return contact.isValid() ? mUnselectedChipTextColor : 964 getResources().getColor(android.R.color.black); 965 } 966 getDefaultChipBackgroundColor(RecipientEntry contact)967 private int getDefaultChipBackgroundColor(RecipientEntry contact) { 968 return contact.isValid() ? mUnselectedChipBackgroundColor : 969 getResources().getColor(R.color.chip_background_invalid); 970 } 971 972 /** 973 * Given a height, returns a Y offset that will draw the text in the middle of the height. 974 */ getTextYOffset(int height)975 protected float getTextYOffset(int height) { 976 return height - ((height - mTextHeight) / 2); 977 } 978 979 /** 980 * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination 981 * rectangle of the canvas. 982 */ drawIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst)983 protected void drawIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) { 984 final Matrix matrix = new Matrix(); 985 986 // Draw bitmap through shader first. 987 final BitmapShader shader = new BitmapShader(icon, TileMode.CLAMP, TileMode.CLAMP); 988 matrix.reset(); 989 990 // Fit bitmap to bounds. 991 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); 992 993 shader.setLocalMatrix(matrix); 994 mWorkPaint.reset(); 995 mWorkPaint.setShader(shader); 996 mWorkPaint.setAntiAlias(true); 997 mWorkPaint.setFilterBitmap(true); 998 mWorkPaint.setDither(true); 999 canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f, mWorkPaint); 1000 1001 // Then draw the border. 1002 final float borderWidth = 1f; 1003 mWorkPaint.reset(); 1004 mWorkPaint.setColor(Color.TRANSPARENT); 1005 mWorkPaint.setStyle(Style.STROKE); 1006 mWorkPaint.setStrokeWidth(borderWidth); 1007 mWorkPaint.setAntiAlias(true); 1008 canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f - borderWidth / 2, 1009 mWorkPaint); 1010 1011 mWorkPaint.reset(); 1012 } 1013 constructChipSpan(RecipientEntry contact)1014 private DrawableRecipientChip constructChipSpan(RecipientEntry contact) { 1015 TextPaint paint = getPaint(); 1016 float defaultSize = paint.getTextSize(); 1017 int defaultColor = paint.getColor(); 1018 1019 Bitmap tmpBitmap = createChipBitmap(contact, paint); 1020 1021 // Pass the full text, un-ellipsized, to the chip. 1022 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 1023 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 1024 VisibleRecipientChip recipientChip = 1025 new VisibleRecipientChip(result, contact); 1026 recipientChip.setExtraMargin(mLineSpacingExtra); 1027 // Return text to the original size. 1028 paint.setTextSize(defaultSize); 1029 paint.setColor(defaultColor); 1030 return recipientChip; 1031 } 1032 1033 /** 1034 * Calculate the offset from bottom of the EditText to top of the provided line. 1035 */ calculateOffsetFromBottomToTop(int line)1036 private int calculateOffsetFromBottomToTop(int line) { 1037 return -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math 1038 .abs(getLineCount() - line)) + getPaddingBottom()); 1039 } 1040 1041 /** 1042 * Get the max amount of space a chip can take up. The formula takes into 1043 * account the width of the EditTextView, any view padding, and padding 1044 * that will be added to the chip. 1045 */ calculateAvailableWidth()1046 private float calculateAvailableWidth() { 1047 return getWidth() - getPaddingLeft() - getPaddingRight() - mChipTextStartPadding 1048 - mChipTextEndPadding; 1049 } 1050 1051 setChipDimensions(Context context, AttributeSet attrs)1052 private void setChipDimensions(Context context, AttributeSet attrs) { 1053 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0, 1054 0); 1055 Resources r = getContext().getResources(); 1056 1057 mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground); 1058 mInvalidChipBackground = a 1059 .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground); 1060 mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete); 1061 if (mChipDelete == null) { 1062 mChipDelete = r.getDrawable(R.drawable.ic_cancel_wht_24dp); 1063 } 1064 mChipTextStartPadding = mChipTextEndPadding 1065 = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1); 1066 if (mChipTextStartPadding == -1) { 1067 mChipTextStartPadding = mChipTextEndPadding = 1068 (int) r.getDimension(R.dimen.chip_padding); 1069 } 1070 // xml-overrides for each individual padding 1071 // TODO: add these to attr? 1072 int overridePadding = (int) r.getDimension(R.dimen.chip_padding_start); 1073 if (overridePadding >= 0) { 1074 mChipTextStartPadding = overridePadding; 1075 } 1076 overridePadding = (int) r.getDimension(R.dimen.chip_padding_end); 1077 if (overridePadding >= 0) { 1078 mChipTextEndPadding = overridePadding; 1079 } 1080 1081 mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture); 1082 1083 mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null); 1084 1085 mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1); 1086 if (mChipHeight == -1) { 1087 mChipHeight = r.getDimension(R.dimen.chip_height); 1088 } 1089 mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1); 1090 if (mChipFontSize == -1) { 1091 mChipFontSize = r.getDimension(R.dimen.chip_text_size); 1092 } 1093 mAvatarPosition = 1094 a.getInt(R.styleable.RecipientEditTextView_avatarPosition, AVATAR_POSITION_START); 1095 mDisableDelete = a.getBoolean(R.styleable.RecipientEditTextView_disableDelete, false); 1096 1097 mMaxLines = r.getInteger(R.integer.chips_max_lines); 1098 mLineSpacingExtra = r.getDimensionPixelOffset(R.dimen.line_spacing_extra); 1099 1100 mUnselectedChipTextColor = a.getColor( 1101 R.styleable.RecipientEditTextView_unselectedChipTextColor, 1102 r.getColor(android.R.color.black)); 1103 1104 mUnselectedChipBackgroundColor = a.getColor( 1105 R.styleable.RecipientEditTextView_unselectedChipBackgroundColor, 1106 r.getColor(R.color.chip_background)); 1107 1108 a.recycle(); 1109 } 1110 1111 // Visible for testing. setMoreItem(TextView moreItem)1112 /* package */ void setMoreItem(TextView moreItem) { 1113 mMoreItem = moreItem; 1114 } 1115 1116 1117 // Visible for testing. setChipBackground(Drawable chipBackground)1118 /* package */ void setChipBackground(Drawable chipBackground) { 1119 mChipBackground = chipBackground; 1120 } 1121 1122 // Visible for testing. setChipHeight(int height)1123 /* package */ void setChipHeight(int height) { 1124 mChipHeight = height; 1125 } 1126 getChipHeight()1127 public float getChipHeight() { 1128 return mChipHeight; 1129 } 1130 1131 /** Returns whether view is in no-chip or chip mode. */ isNoChipMode()1132 public boolean isNoChipMode() { 1133 return mNoChipMode; 1134 } 1135 1136 /** 1137 * Set whether to shrink the recipients field such that at most 1138 * one line of recipients chips are shown when the field loses 1139 * focus. By default, the number of displayed recipients will be 1140 * limited and a "more" chip will be shown when focus is lost. 1141 * @param shrink 1142 */ setOnFocusListShrinkRecipients(boolean shrink)1143 public void setOnFocusListShrinkRecipients(boolean shrink) { 1144 mShouldShrink = shrink; 1145 } 1146 1147 @Override onSizeChanged(int width, int height, int oldw, int oldh)1148 public void onSizeChanged(int width, int height, int oldw, int oldh) { 1149 super.onSizeChanged(width, height, oldw, oldh); 1150 if (width != 0 && height != 0) { 1151 if (mPendingChipsCount > 0) { 1152 postHandlePendingChips(); 1153 } else { 1154 checkChipWidths(); 1155 } 1156 } 1157 // Try to find the scroll view parent, if it exists. 1158 if (mScrollView == null && !mTriedGettingScrollView) { 1159 ViewParent parent = getParent(); 1160 while (parent != null && !(parent instanceof ScrollView)) { 1161 parent = parent.getParent(); 1162 } 1163 if (parent != null) { 1164 mScrollView = (ScrollView) parent; 1165 } 1166 mTriedGettingScrollView = true; 1167 } 1168 } 1169 postHandlePendingChips()1170 private void postHandlePendingChips() { 1171 mHandler.removeCallbacks(mHandlePendingChips); 1172 mHandler.post(mHandlePendingChips); 1173 } 1174 checkChipWidths()1175 private void checkChipWidths() { 1176 // Check the widths of the associated chips. 1177 DrawableRecipientChip[] chips = getSortedRecipients(); 1178 if (chips != null) { 1179 Rect bounds; 1180 for (DrawableRecipientChip chip : chips) { 1181 bounds = chip.getBounds(); 1182 if (getWidth() > 0 && bounds.right - bounds.left > 1183 getWidth() - getPaddingLeft() - getPaddingRight()) { 1184 // Need to redraw that chip. 1185 replaceChip(chip, chip.getEntry()); 1186 } 1187 } 1188 } 1189 } 1190 1191 // Visible for testing. handlePendingChips()1192 /*package*/ void handlePendingChips() { 1193 if (getViewWidth() <= 0) { 1194 // The widget has not been sized yet. 1195 // This will be called as a result of onSizeChanged 1196 // at a later point. 1197 return; 1198 } 1199 if (mPendingChipsCount <= 0) { 1200 return; 1201 } 1202 1203 synchronized (mPendingChips) { 1204 Editable editable = getText(); 1205 // Tokenize! 1206 if (mPendingChipsCount <= MAX_CHIPS_PARSED) { 1207 for (int i = 0; i < mPendingChips.size(); i++) { 1208 String current = mPendingChips.get(i); 1209 int tokenStart = editable.toString().indexOf(current); 1210 // Always leave a space at the end between tokens. 1211 int tokenEnd = tokenStart + current.length() - 1; 1212 if (tokenStart >= 0) { 1213 // When we have a valid token, include it with the token 1214 // to the left. 1215 if (tokenEnd < editable.length() - 2 1216 && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) { 1217 tokenEnd++; 1218 } 1219 createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT 1220 || !mShouldShrink); 1221 } 1222 mPendingChipsCount--; 1223 } 1224 sanitizeEnd(); 1225 } else { 1226 mNoChipMode = true; 1227 } 1228 1229 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0 1230 && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) { 1231 if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) { 1232 new RecipientReplacementTask().execute(); 1233 mTemporaryRecipients = null; 1234 } else { 1235 // Create the "more" chip 1236 mIndividualReplacements = new IndividualReplacementTask(); 1237 mIndividualReplacements.execute(new ArrayList<DrawableRecipientChip>( 1238 mTemporaryRecipients.subList(0, CHIP_LIMIT))); 1239 if (mTemporaryRecipients.size() > CHIP_LIMIT) { 1240 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>( 1241 mTemporaryRecipients.subList(CHIP_LIMIT, 1242 mTemporaryRecipients.size())); 1243 } else { 1244 mTemporaryRecipients = null; 1245 } 1246 createMoreChip(); 1247 } 1248 } else { 1249 // There are too many recipients to look up, so just fall back 1250 // to showing addresses for all of them. 1251 mTemporaryRecipients = null; 1252 createMoreChip(); 1253 } 1254 mPendingChipsCount = 0; 1255 mPendingChips.clear(); 1256 } 1257 } 1258 1259 // Visible for testing. getViewWidth()1260 /*package*/ int getViewWidth() { 1261 return getWidth(); 1262 } 1263 1264 /** 1265 * Remove any characters after the last valid chip. 1266 */ 1267 // Visible for testing. sanitizeEnd()1268 /*package*/ void sanitizeEnd() { 1269 // Don't sanitize while we are waiting for pending chips to complete. 1270 if (mPendingChipsCount > 0) { 1271 return; 1272 } 1273 // Find the last chip; eliminate any commit characters after it. 1274 DrawableRecipientChip[] chips = getSortedRecipients(); 1275 Spannable spannable = getSpannable(); 1276 if (chips != null && chips.length > 0) { 1277 int end; 1278 mMoreChip = getMoreChip(); 1279 if (mMoreChip != null) { 1280 end = spannable.getSpanEnd(mMoreChip); 1281 } else { 1282 end = getSpannable().getSpanEnd(getLastChip()); 1283 } 1284 Editable editable = getText(); 1285 int length = editable.length(); 1286 if (length > end) { 1287 // See what characters occur after that and eliminate them. 1288 if (Log.isLoggable(TAG, Log.DEBUG)) { 1289 Log.d(TAG, "There were extra characters after the last tokenizable entry." 1290 + editable); 1291 } 1292 editable.delete(end + 1, length); 1293 } 1294 } 1295 } 1296 1297 /** 1298 * Create a chip that represents just the email address of a recipient. At some later 1299 * point, this chip will be attached to a real contact entry, if one exists. 1300 */ 1301 // VisibleForTesting createReplacementChip(int tokenStart, int tokenEnd, Editable editable, boolean visible)1302 void createReplacementChip(int tokenStart, int tokenEnd, Editable editable, 1303 boolean visible) { 1304 if (alreadyHasChip(tokenStart, tokenEnd)) { 1305 // There is already a chip present at this location. 1306 // Don't recreate it. 1307 return; 1308 } 1309 String token = editable.toString().substring(tokenStart, tokenEnd); 1310 final String trimmedToken = token.trim(); 1311 int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA); 1312 if (commitCharIndex != -1 && commitCharIndex == trimmedToken.length() - 1) { 1313 token = trimmedToken.substring(0, trimmedToken.length() - 1); 1314 } 1315 RecipientEntry entry = createTokenizedEntry(token); 1316 if (entry != null) { 1317 DrawableRecipientChip chip = null; 1318 try { 1319 if (!mNoChipMode) { 1320 chip = visible ? constructChipSpan(entry) : new InvisibleRecipientChip(entry); 1321 } 1322 } catch (NullPointerException e) { 1323 Log.e(TAG, e.getMessage(), e); 1324 } 1325 editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1326 // Add this chip to the list of entries "to replace" 1327 if (chip != null) { 1328 if (mTemporaryRecipients == null) { 1329 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>(); 1330 } 1331 chip.setOriginalText(token); 1332 mTemporaryRecipients.add(chip); 1333 } 1334 } 1335 } 1336 1337 // VisibleForTesting createTokenizedEntry(final String token)1338 RecipientEntry createTokenizedEntry(final String token) { 1339 if (TextUtils.isEmpty(token)) { 1340 return null; 1341 } 1342 if (isPhoneQuery() && PhoneUtil.isPhoneNumber(token)) { 1343 return RecipientEntry.constructFakePhoneEntry(token, true); 1344 } 1345 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token); 1346 boolean isValid = isValid(token); 1347 if (isValid && tokens != null && tokens.length > 0) { 1348 // If we can get a name from tokenizing, then generate an entry from 1349 // this. 1350 String display = tokens[0].getName(); 1351 if (!TextUtils.isEmpty(display)) { 1352 return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(), 1353 isValid); 1354 } else { 1355 display = tokens[0].getAddress(); 1356 if (!TextUtils.isEmpty(display)) { 1357 return RecipientEntry.constructFakeEntry(display, isValid); 1358 } 1359 } 1360 } 1361 // Unable to validate the token or to create a valid token from it. 1362 // Just create a chip the user can edit. 1363 String validatedToken = null; 1364 if (mValidator != null && !isValid) { 1365 // Try fixing up the entry using the validator. 1366 validatedToken = mValidator.fixText(token).toString(); 1367 if (!TextUtils.isEmpty(validatedToken)) { 1368 if (validatedToken.contains(token)) { 1369 // protect against the case of a validator with a null 1370 // domain, 1371 // which doesn't add a domain to the token 1372 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken); 1373 if (tokenized.length > 0) { 1374 validatedToken = tokenized[0].getAddress(); 1375 isValid = true; 1376 } 1377 } else { 1378 // We ran into a case where the token was invalid and 1379 // removed 1380 // by the validator. In this case, just use the original 1381 // token 1382 // and let the user sort out the error chip. 1383 validatedToken = null; 1384 isValid = false; 1385 } 1386 } 1387 } 1388 // Otherwise, fallback to just creating an editable email address chip. 1389 return RecipientEntry.constructFakeEntry( 1390 !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid); 1391 } 1392 isValid(String text)1393 private boolean isValid(String text) { 1394 return mValidator == null ? true : mValidator.isValid(text); 1395 } 1396 tokenizeAddress(String destination)1397 private static String tokenizeAddress(String destination) { 1398 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination); 1399 if (tokens != null && tokens.length > 0) { 1400 return tokens[0].getAddress(); 1401 } 1402 return destination; 1403 } 1404 1405 @Override setTokenizer(Tokenizer tokenizer)1406 public void setTokenizer(Tokenizer tokenizer) { 1407 mTokenizer = tokenizer; 1408 super.setTokenizer(mTokenizer); 1409 } 1410 1411 @Override setValidator(Validator validator)1412 public void setValidator(Validator validator) { 1413 mValidator = validator; 1414 super.setValidator(validator); 1415 } 1416 1417 /** 1418 * We cannot use the default mechanism for replaceText. Instead, 1419 * we override onItemClickListener so we can get all the associated 1420 * contact information including display text, address, and id. 1421 */ 1422 @Override replaceText(CharSequence text)1423 protected void replaceText(CharSequence text) { 1424 return; 1425 } 1426 1427 /** 1428 * Dismiss any selected chips when the back key is pressed. 1429 */ 1430 @Override onKeyPreIme(int keyCode, @NonNull KeyEvent event)1431 public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) { 1432 if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) { 1433 clearSelectedChip(); 1434 return true; 1435 } 1436 return super.onKeyPreIme(keyCode, event); 1437 } 1438 1439 /** 1440 * Monitor key presses in this view to see if the user types 1441 * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. 1442 * If the user has entered text that has contact matches and types 1443 * a commit key, create a chip from the topmost matching contact. 1444 * If the user has entered text that has no contact matches and types 1445 * a commit key, then create a chip from the text they have entered. 1446 */ 1447 @Override onKeyUp(int keyCode, @NonNull KeyEvent event)1448 public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { 1449 switch (keyCode) { 1450 case KeyEvent.KEYCODE_TAB: 1451 if (event.hasNoModifiers()) { 1452 if (mSelectedChip != null) { 1453 clearSelectedChip(); 1454 } else { 1455 commitDefault(); 1456 } 1457 } 1458 break; 1459 } 1460 return super.onKeyUp(keyCode, event); 1461 } 1462 focusNext()1463 private boolean focusNext() { 1464 View next = focusSearch(View.FOCUS_DOWN); 1465 if (next != null) { 1466 next.requestFocus(); 1467 return true; 1468 } 1469 return false; 1470 } 1471 1472 /** 1473 * Create a chip from the default selection. If the popup is showing, the 1474 * default is the selected item (if one is selected), or the first item, in the popup 1475 * suggestions list. Otherwise, it is whatever the user had typed in. End represents where the 1476 * tokenizer should search for a token to turn into a chip. 1477 * @return If a chip was created from a real contact. 1478 */ commitDefault()1479 private boolean commitDefault() { 1480 // If there is no tokenizer, don't try to commit. 1481 if (mTokenizer == null) { 1482 return false; 1483 } 1484 Editable editable = getText(); 1485 int end = getSelectionEnd(); 1486 int start = mTokenizer.findTokenStart(editable, end); 1487 1488 if (shouldCreateChip(start, end)) { 1489 int whatEnd = mTokenizer.findTokenEnd(getText(), start); 1490 // In the middle of chip; treat this as an edit 1491 // and commit the whole token. 1492 whatEnd = movePastTerminators(whatEnd); 1493 if (whatEnd != getSelectionEnd()) { 1494 handleEdit(start, whatEnd); 1495 return true; 1496 } 1497 return commitChip(start, end , editable); 1498 } 1499 return false; 1500 } 1501 commitByCharacter()1502 private void commitByCharacter() { 1503 // We can't possibly commit by character if we can't tokenize. 1504 if (mTokenizer == null) { 1505 return; 1506 } 1507 Editable editable = getText(); 1508 int end = getSelectionEnd(); 1509 int start = mTokenizer.findTokenStart(editable, end); 1510 if (shouldCreateChip(start, end)) { 1511 commitChip(start, end, editable); 1512 } 1513 setSelection(getText().length()); 1514 } 1515 commitChip(int start, int end, Editable editable)1516 private boolean commitChip(int start, int end, Editable editable) { 1517 int position = positionOfFirstEntryWithTypePerson(); 1518 if (position != -1 && enoughToFilter() 1519 && end == getSelectionEnd() && !isPhoneQuery()) { 1520 // let's choose the selected or first entry if only the input text is NOT an email 1521 // address so we won't try to replace the user's potentially correct but 1522 // new/unencountered email input 1523 if (!isValidEmailAddress(editable.toString().substring(start, end).trim())) { 1524 final int selectedPosition = getListSelection(); 1525 if (selectedPosition == -1 || !isEntryAtPositionTypePerson(selectedPosition)) { 1526 // Nothing is selected or selected item is not type person; use the first item 1527 submitItemAtPosition(position); 1528 } else { 1529 submitItemAtPosition(selectedPosition); 1530 } 1531 } 1532 dismissDropDown(); 1533 return true; 1534 } else { 1535 int tokenEnd = mTokenizer.findTokenEnd(editable, start); 1536 if (editable.length() > tokenEnd + 1) { 1537 char charAt = editable.charAt(tokenEnd + 1); 1538 if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) { 1539 tokenEnd++; 1540 } 1541 } 1542 String text = editable.toString().substring(start, tokenEnd).trim(); 1543 clearComposingText(); 1544 if (text.length() > 0 && !text.equals(" ")) { 1545 RecipientEntry entry = createTokenizedEntry(text); 1546 if (entry != null) { 1547 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1548 CharSequence chipText = createChip(entry); 1549 if (chipText != null && start > -1 && end > -1) { 1550 editable.replace(start, end, chipText); 1551 } 1552 } 1553 // Only dismiss the dropdown if it is related to the text we 1554 // just committed. 1555 // For paste, it may not be as there are possibly multiple 1556 // tokens being added. 1557 if (end == getSelectionEnd()) { 1558 dismissDropDown(); 1559 } 1560 sanitizeBetween(); 1561 return true; 1562 } 1563 } 1564 return false; 1565 } 1566 positionOfFirstEntryWithTypePerson()1567 private int positionOfFirstEntryWithTypePerson() { 1568 ListAdapter adapter = getAdapter(); 1569 int itemCount = adapter != null ? adapter.getCount() : 0; 1570 for (int i = 0; i < itemCount; i++) { 1571 if (isEntryAtPositionTypePerson(i)) { 1572 return i; 1573 } 1574 } 1575 return -1; 1576 } 1577 isEntryAtPositionTypePerson(int position)1578 private boolean isEntryAtPositionTypePerson(int position) { 1579 return getAdapter().getItem(position).getEntryType() == RecipientEntry.ENTRY_TYPE_PERSON; 1580 } 1581 1582 // Visible for testing. sanitizeBetween()1583 /* package */ void sanitizeBetween() { 1584 // Don't sanitize while we are waiting for content to chipify. 1585 if (mPendingChipsCount > 0) { 1586 return; 1587 } 1588 // Find the last chip. 1589 DrawableRecipientChip[] recips = getSortedRecipients(); 1590 if (recips != null && recips.length > 0) { 1591 DrawableRecipientChip last = recips[recips.length - 1]; 1592 DrawableRecipientChip beforeLast = null; 1593 if (recips.length > 1) { 1594 beforeLast = recips[recips.length - 2]; 1595 } 1596 int startLooking = 0; 1597 int end = getSpannable().getSpanStart(last); 1598 if (beforeLast != null) { 1599 startLooking = getSpannable().getSpanEnd(beforeLast); 1600 Editable text = getText(); 1601 if (startLooking == -1 || startLooking > text.length() - 1) { 1602 // There is nothing after this chip. 1603 return; 1604 } 1605 if (text.charAt(startLooking) == ' ') { 1606 startLooking++; 1607 } 1608 } 1609 if (startLooking >= 0 && end >= 0 && startLooking < end) { 1610 getText().delete(startLooking, end); 1611 } 1612 } 1613 } 1614 shouldCreateChip(int start, int end)1615 private boolean shouldCreateChip(int start, int end) { 1616 return !mNoChipMode && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end); 1617 } 1618 alreadyHasChip(int start, int end)1619 private boolean alreadyHasChip(int start, int end) { 1620 if (mNoChipMode) { 1621 return true; 1622 } 1623 DrawableRecipientChip[] chips = 1624 getSpannable().getSpans(start, end, DrawableRecipientChip.class); 1625 return chips != null && chips.length > 0; 1626 } 1627 handleEdit(int start, int end)1628 private void handleEdit(int start, int end) { 1629 if (start == -1 || end == -1) { 1630 // This chip no longer exists in the field. 1631 dismissDropDown(); 1632 return; 1633 } 1634 // This is in the middle of a chip, so select out the whole chip 1635 // and commit it. 1636 Editable editable = getText(); 1637 setSelection(end); 1638 String text = getText().toString().substring(start, end); 1639 if (!TextUtils.isEmpty(text)) { 1640 RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text)); 1641 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1642 CharSequence chipText = createChip(entry); 1643 int selEnd = getSelectionEnd(); 1644 if (chipText != null && start > -1 && selEnd > -1) { 1645 editable.replace(start, selEnd, chipText); 1646 } 1647 } 1648 dismissDropDown(); 1649 } 1650 1651 /** 1652 * If there is a selected chip, delegate the key events 1653 * to the selected chip. 1654 */ 1655 @Override onKeyDown(int keyCode, @NonNull KeyEvent event)1656 public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { 1657 if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) { 1658 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 1659 mAlternatesPopup.dismiss(); 1660 } 1661 removeChip(mSelectedChip); 1662 } 1663 1664 switch (keyCode) { 1665 case KeyEvent.KEYCODE_ENTER: 1666 case KeyEvent.KEYCODE_DPAD_CENTER: 1667 if (event.hasNoModifiers()) { 1668 if (commitDefault()) { 1669 return true; 1670 } 1671 if (mSelectedChip != null) { 1672 clearSelectedChip(); 1673 return true; 1674 } else if (focusNext()) { 1675 return true; 1676 } 1677 } 1678 break; 1679 } 1680 1681 return super.onKeyDown(keyCode, event); 1682 } 1683 1684 // Visible for testing. getSpannable()1685 /* package */ Spannable getSpannable() { 1686 return getText(); 1687 } 1688 getChipStart(DrawableRecipientChip chip)1689 private int getChipStart(DrawableRecipientChip chip) { 1690 return getSpannable().getSpanStart(chip); 1691 } 1692 getChipEnd(DrawableRecipientChip chip)1693 private int getChipEnd(DrawableRecipientChip chip) { 1694 return getSpannable().getSpanEnd(chip); 1695 } 1696 1697 /** 1698 * Instead of filtering on the entire contents of the edit box, 1699 * this subclass method filters on the range from 1700 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 1701 * if the length of that range meets or exceeds {@link #getThreshold} 1702 * and makes sure that the range is not already a Chip. 1703 */ 1704 @Override performFiltering(@onNull CharSequence text, int keyCode)1705 public void performFiltering(@NonNull CharSequence text, int keyCode) { 1706 boolean isCompletedToken = isCompletedToken(text); 1707 if (enoughToFilter() && !isCompletedToken) { 1708 int end = getSelectionEnd(); 1709 int start = mTokenizer.findTokenStart(text, end); 1710 // If this is a RecipientChip, don't filter 1711 // on its contents. 1712 Spannable span = getSpannable(); 1713 DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class); 1714 if (chips != null && chips.length > 0) { 1715 dismissDropDown(); 1716 return; 1717 } 1718 } else if (isCompletedToken) { 1719 dismissDropDown(); 1720 return; 1721 } 1722 super.performFiltering(text, keyCode); 1723 } 1724 1725 // Visible for testing. isCompletedToken(CharSequence text)1726 /*package*/ boolean isCompletedToken(CharSequence text) { 1727 if (TextUtils.isEmpty(text)) { 1728 return false; 1729 } 1730 // Check to see if this is a completed token before filtering. 1731 int end = text.length(); 1732 int start = mTokenizer.findTokenStart(text, end); 1733 String token = text.toString().substring(start, end).trim(); 1734 if (!TextUtils.isEmpty(token)) { 1735 char atEnd = token.charAt(token.length() - 1); 1736 return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON; 1737 } 1738 return false; 1739 } 1740 1741 /** 1742 * Clears the selected chip if there is one (and dismissing any popups related to the selected 1743 * chip in the process). 1744 */ clearSelectedChip()1745 public void clearSelectedChip() { 1746 if (mSelectedChip != null) { 1747 unselectChip(mSelectedChip); 1748 mSelectedChip = null; 1749 } 1750 setCursorVisible(true); 1751 setSelection(getText().length()); 1752 } 1753 1754 /** 1755 * Monitor touch events in the RecipientEditTextView. 1756 * If the view does not have focus, any tap on the view 1757 * will just focus the view. If the view has focus, determine 1758 * if the touch target is a recipient chip. If it is and the chip 1759 * is not selected, select it and clear any other selected chips. 1760 * If it isn't, then select that chip. 1761 */ 1762 @Override onTouchEvent(@onNull MotionEvent event)1763 public boolean onTouchEvent(@NonNull MotionEvent event) { 1764 if (!isFocused()) { 1765 // Ignore any chip taps until this view is focused. 1766 return super.onTouchEvent(event); 1767 } 1768 boolean handled = super.onTouchEvent(event); 1769 int action = event.getAction(); 1770 boolean chipWasSelected = false; 1771 if (mSelectedChip == null) { 1772 mGestureDetector.onTouchEvent(event); 1773 } 1774 if (action == MotionEvent.ACTION_UP) { 1775 float x = event.getX(); 1776 float y = event.getY(); 1777 int offset = putOffsetInRange(x, y); 1778 DrawableRecipientChip currentChip = findChip(offset); 1779 if (currentChip != null) { 1780 if (mSelectedChip != null && mSelectedChip != currentChip) { 1781 clearSelectedChip(); 1782 selectChip(currentChip); 1783 } else if (mSelectedChip == null) { 1784 commitDefault(); 1785 selectChip(currentChip); 1786 } else { 1787 onClick(mSelectedChip); 1788 } 1789 chipWasSelected = true; 1790 handled = true; 1791 } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) { 1792 chipWasSelected = true; 1793 } 1794 } 1795 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 1796 clearSelectedChip(); 1797 } 1798 return handled; 1799 } 1800 showAlternates(final DrawableRecipientChip currentChip, final ListPopupWindow alternatesPopup)1801 private void showAlternates(final DrawableRecipientChip currentChip, 1802 final ListPopupWindow alternatesPopup) { 1803 new AsyncTask<Void, Void, ListAdapter>() { 1804 @Override 1805 protected ListAdapter doInBackground(final Void... params) { 1806 return createAlternatesAdapter(currentChip); 1807 } 1808 1809 @Override 1810 protected void onPostExecute(final ListAdapter result) { 1811 if (!mAttachedToWindow) { 1812 return; 1813 } 1814 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 1815 int bottomOffset = calculateOffsetFromBottomToTop(line); 1816 1817 // Align the alternates popup with the left side of the View, 1818 // regardless of the position of the chip tapped. 1819 alternatesPopup.setAnchorView((mAlternatePopupAnchor != null) ? 1820 mAlternatePopupAnchor : RecipientEditTextView.this); 1821 alternatesPopup.setVerticalOffset(bottomOffset); 1822 alternatesPopup.setAdapter(result); 1823 alternatesPopup.setOnItemClickListener(mAlternatesListener); 1824 // Clear the checked item. 1825 mCheckedItem = -1; 1826 alternatesPopup.show(); 1827 ListView listView = alternatesPopup.getListView(); 1828 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1829 // Checked item would be -1 if the adapter has not 1830 // loaded the view that should be checked yet. The 1831 // variable will be set correctly when onCheckedItemChanged 1832 // is called in a separate thread. 1833 if (mCheckedItem != -1) { 1834 listView.setItemChecked(mCheckedItem, true); 1835 mCheckedItem = -1; 1836 } 1837 } 1838 }.execute((Void[]) null); 1839 } 1840 createAlternatesAdapter(DrawableRecipientChip chip)1841 protected ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) { 1842 return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), 1843 chip.getDirectoryId(), chip.getLookupKey(), chip.getDataId(), 1844 getAdapter().getQueryType(), this, mDropdownChipLayouter, 1845 constructStateListDeleteDrawable(), getAdapter().getPermissionsCheckListener()); 1846 } 1847 createSingleAddressAdapter(DrawableRecipientChip currentChip)1848 private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) { 1849 return new SingleRecipientArrayAdapter(getContext(), currentChip.getEntry(), 1850 mDropdownChipLayouter, constructStateListDeleteDrawable()); 1851 } 1852 constructStateListDeleteDrawable()1853 private StateListDrawable constructStateListDeleteDrawable() { 1854 // Construct the StateListDrawable from deleteDrawable 1855 StateListDrawable deleteDrawable = new StateListDrawable(); 1856 if (!mDisableDelete) { 1857 deleteDrawable.addState(new int[]{android.R.attr.state_activated}, mChipDelete); 1858 } 1859 deleteDrawable.addState(new int[0], null); 1860 return deleteDrawable; 1861 } 1862 1863 @Override onCheckedItemChanged(int position)1864 public void onCheckedItemChanged(int position) { 1865 ListView listView = mAlternatesPopup.getListView(); 1866 if (listView != null && listView.getCheckedItemCount() == 0) { 1867 listView.setItemChecked(position, true); 1868 } 1869 mCheckedItem = position; 1870 } 1871 putOffsetInRange(final float x, final float y)1872 private int putOffsetInRange(final float x, final float y) { 1873 final int offset; 1874 1875 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 1876 offset = getOffsetForPosition(x, y); 1877 } else { 1878 offset = supportGetOffsetForPosition(x, y); 1879 } 1880 1881 return putOffsetInRange(offset); 1882 } 1883 1884 // TODO: This algorithm will need a lot of tweaking after more people have used 1885 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 1886 // what comes before the finger. putOffsetInRange(int o)1887 private int putOffsetInRange(int o) { 1888 int offset = o; 1889 Editable text = getText(); 1890 int length = text.length(); 1891 // Remove whitespace from end to find "real end" 1892 int realLength = length; 1893 for (int i = length - 1; i >= 0; i--) { 1894 if (text.charAt(i) == ' ') { 1895 realLength--; 1896 } else { 1897 break; 1898 } 1899 } 1900 1901 // If the offset is beyond or at the end of the text, 1902 // leave it alone. 1903 if (offset >= realLength) { 1904 return offset; 1905 } 1906 Editable editable = getText(); 1907 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 1908 // Keep walking backward! 1909 offset--; 1910 } 1911 return offset; 1912 } 1913 findText(Editable text, int offset)1914 private static int findText(Editable text, int offset) { 1915 if (text.charAt(offset) != ' ') { 1916 return offset; 1917 } 1918 return -1; 1919 } 1920 findChip(int offset)1921 private DrawableRecipientChip findChip(int offset) { 1922 final Spannable span = getSpannable(); 1923 final DrawableRecipientChip[] chips = 1924 span.getSpans(0, span.length(), DrawableRecipientChip.class); 1925 // Find the chip that contains this offset. 1926 for (DrawableRecipientChip chip : chips) { 1927 int start = getChipStart(chip); 1928 int end = getChipEnd(chip); 1929 if (offset >= start && offset <= end) { 1930 return chip; 1931 } 1932 } 1933 return null; 1934 } 1935 1936 // Visible for testing. 1937 // Use this method to generate text to add to the list of addresses. createAddressText(RecipientEntry entry)1938 /* package */String createAddressText(RecipientEntry entry) { 1939 String display = entry.getDisplayName(); 1940 String address = entry.getDestination(); 1941 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1942 display = null; 1943 } 1944 String trimmedDisplayText; 1945 if (isPhoneQuery() && PhoneUtil.isPhoneNumber(address)) { 1946 trimmedDisplayText = address.trim(); 1947 } else { 1948 if (address != null) { 1949 // Tokenize out the address in case the address already 1950 // contained the username as well. 1951 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address); 1952 if (tokenized != null && tokenized.length > 0) { 1953 address = tokenized[0].getAddress(); 1954 } 1955 } 1956 Rfc822Token token = new Rfc822Token(display, address, null); 1957 trimmedDisplayText = token.toString().trim(); 1958 } 1959 int index = trimmedDisplayText.indexOf(","); 1960 return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText) 1961 && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer 1962 .terminateToken(trimmedDisplayText) : trimmedDisplayText; 1963 } 1964 1965 // Visible for testing. 1966 // Use this method to generate text to display in a chip. createChipDisplayText(RecipientEntry entry)1967 /*package*/ String createChipDisplayText(RecipientEntry entry) { 1968 String display = entry.getDisplayName(); 1969 String address = entry.getDestination(); 1970 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1971 display = null; 1972 } 1973 if (!TextUtils.isEmpty(display)) { 1974 return display; 1975 } else if (!TextUtils.isEmpty(address)){ 1976 return address; 1977 } else { 1978 return new Rfc822Token(display, address, null).toString(); 1979 } 1980 } 1981 createChip(RecipientEntry entry)1982 private CharSequence createChip(RecipientEntry entry) { 1983 final String displayText = createAddressText(entry); 1984 if (TextUtils.isEmpty(displayText)) { 1985 return null; 1986 } 1987 // Always leave a blank space at the end of a chip. 1988 final int textLength = displayText.length() - 1; 1989 final SpannableString chipText = new SpannableString(displayText); 1990 if (!mNoChipMode) { 1991 try { 1992 DrawableRecipientChip chip = constructChipSpan(entry); 1993 chipText.setSpan(chip, 0, textLength, 1994 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1995 chip.setOriginalText(chipText.toString()); 1996 } catch (NullPointerException e) { 1997 Log.e(TAG, e.getMessage(), e); 1998 return null; 1999 } 2000 } 2001 onChipCreated(entry); 2002 return chipText; 2003 } 2004 2005 /** 2006 * A callback for subclasses to use to know when a chip was created with the 2007 * given RecipientEntry. 2008 */ onChipCreated(RecipientEntry entry)2009 protected void onChipCreated(RecipientEntry entry) { 2010 if (!mNoChipMode && mRecipientChipAddedListener != null) { 2011 mRecipientChipAddedListener.onRecipientChipAdded(entry); 2012 } 2013 } 2014 2015 /** 2016 * When an item in the suggestions list has been clicked, create a chip from the 2017 * contact information of the selected item. 2018 */ 2019 @Override onItemClick(AdapterView<?> parent, View view, int position, long id)2020 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 2021 if (position < 0) { 2022 return; 2023 } 2024 2025 final RecipientEntry entry = getAdapter().getItem(position); 2026 if (entry.getEntryType() == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) { 2027 if (mPermissionsRequestItemClickedListener != null) { 2028 mPermissionsRequestItemClickedListener 2029 .onPermissionsRequestItemClicked(this, entry.getPermissions()); 2030 } 2031 return; 2032 } 2033 2034 final int charactersTyped = submitItemAtPosition(position); 2035 if (charactersTyped > -1 && mRecipientEntryItemClickedListener != null) { 2036 mRecipientEntryItemClickedListener 2037 .onRecipientEntryItemClicked(charactersTyped, position); 2038 } 2039 } 2040 submitItemAtPosition(int position)2041 private int submitItemAtPosition(int position) { 2042 RecipientEntry entry = createValidatedEntry(getAdapter().getItem(position)); 2043 if (entry == null) { 2044 return -1; 2045 } 2046 clearComposingText(); 2047 2048 int end = getSelectionEnd(); 2049 int start = mTokenizer.findTokenStart(getText(), end); 2050 2051 Editable editable = getText(); 2052 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 2053 CharSequence chip = createChip(entry); 2054 if (chip != null && start >= 0 && end >= 0) { 2055 editable.replace(start, end, chip); 2056 } 2057 sanitizeBetween(); 2058 2059 return end - start; 2060 } 2061 createValidatedEntry(RecipientEntry item)2062 private RecipientEntry createValidatedEntry(RecipientEntry item) { 2063 if (item == null) { 2064 return null; 2065 } 2066 final RecipientEntry entry; 2067 // If the display name and the address are the same, or if this is a 2068 // valid contact, but the destination is invalid, then make this a fake 2069 // recipient that is editable. 2070 String destination = item.getDestination(); 2071 if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) { 2072 entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(), 2073 destination, item.isValid()); 2074 } else if (RecipientEntry.isCreatedRecipient(item.getContactId()) 2075 && (TextUtils.isEmpty(item.getDisplayName()) 2076 || TextUtils.equals(item.getDisplayName(), destination) 2077 || (mValidator != null && !mValidator.isValid(destination)))) { 2078 entry = RecipientEntry.constructFakeEntry(destination, item.isValid()); 2079 } else { 2080 entry = item; 2081 } 2082 return entry; 2083 } 2084 2085 // Visible for testing. getSortedRecipients()2086 /* package */DrawableRecipientChip[] getSortedRecipients() { 2087 DrawableRecipientChip[] recips = getSpannable() 2088 .getSpans(0, getText().length(), DrawableRecipientChip.class); 2089 ArrayList<DrawableRecipientChip> recipientsList = new ArrayList<DrawableRecipientChip>( 2090 Arrays.asList(recips)); 2091 final Spannable spannable = getSpannable(); 2092 Collections.sort(recipientsList, new Comparator<DrawableRecipientChip>() { 2093 2094 @Override 2095 public int compare(DrawableRecipientChip first, DrawableRecipientChip second) { 2096 int firstStart = spannable.getSpanStart(first); 2097 int secondStart = spannable.getSpanStart(second); 2098 if (firstStart < secondStart) { 2099 return -1; 2100 } else if (firstStart > secondStart) { 2101 return 1; 2102 } else { 2103 return 0; 2104 } 2105 } 2106 }); 2107 return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]); 2108 } 2109 2110 @Override onActionItemClicked(ActionMode mode, MenuItem item)2111 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 2112 return false; 2113 } 2114 2115 @Override onDestroyActionMode(ActionMode mode)2116 public void onDestroyActionMode(ActionMode mode) { 2117 } 2118 2119 @Override onPrepareActionMode(ActionMode mode, Menu menu)2120 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 2121 return false; 2122 } 2123 2124 /** 2125 * No chips are selectable. 2126 */ 2127 @Override onCreateActionMode(ActionMode mode, Menu menu)2128 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 2129 return false; 2130 } 2131 2132 // Visible for testing. getMoreChip()2133 /* package */ReplacementDrawableSpan getMoreChip() { 2134 MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(), 2135 MoreImageSpan.class); 2136 return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null; 2137 } 2138 createMoreSpan(int count)2139 private MoreImageSpan createMoreSpan(int count) { 2140 String moreText = String.format(mMoreItem.getText().toString(), count); 2141 mWorkPaint.set(getPaint()); 2142 mWorkPaint.setTextSize(mMoreItem.getTextSize()); 2143 mWorkPaint.setColor(mMoreItem.getCurrentTextColor()); 2144 final int width = (int) mWorkPaint.measureText(moreText) + mMoreItem.getPaddingLeft() 2145 + mMoreItem.getPaddingRight(); 2146 final int height = (int) mChipHeight; 2147 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 2148 Canvas canvas = new Canvas(drawable); 2149 int adjustedHeight = height; 2150 Layout layout = getLayout(); 2151 if (layout != null) { 2152 adjustedHeight -= layout.getLineDescent(0); 2153 } 2154 canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, mWorkPaint); 2155 2156 Drawable result = new BitmapDrawable(getResources(), drawable); 2157 result.setBounds(0, 0, width, height); 2158 return new MoreImageSpan(result); 2159 } 2160 2161 // Visible for testing. createMoreChipPlainText()2162 /*package*/ void createMoreChipPlainText() { 2163 // Take the first <= CHIP_LIMIT addresses and get to the end of the second one. 2164 Editable text = getText(); 2165 int start = 0; 2166 int end = start; 2167 for (int i = 0; i < CHIP_LIMIT; i++) { 2168 end = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 2169 start = end; // move to the next token and get its end. 2170 } 2171 // Now, count total addresses. 2172 int tokenCount = countTokens(text); 2173 MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT); 2174 SpannableString chipText = new SpannableString(text.subSequence(end, text.length())); 2175 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2176 text.replace(end, text.length(), chipText); 2177 mMoreChip = moreSpan; 2178 } 2179 2180 // Visible for testing. countTokens(Editable text)2181 /* package */int countTokens(Editable text) { 2182 int tokenCount = 0; 2183 int start = 0; 2184 while (start < text.length()) { 2185 start = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 2186 tokenCount++; 2187 if (start >= text.length()) { 2188 break; 2189 } 2190 } 2191 return tokenCount; 2192 } 2193 2194 /** 2195 * Create the more chip. The more chip is text that replaces any chips that 2196 * do not fit in the pre-defined available space when the 2197 * RecipientEditTextView loses focus. 2198 */ 2199 // Visible for testing. createMoreChip()2200 /* package */ void createMoreChip() { 2201 if (mNoChipMode) { 2202 createMoreChipPlainText(); 2203 return; 2204 } 2205 2206 if (!mShouldShrink) { 2207 return; 2208 } 2209 ReplacementDrawableSpan[] tempMore = getSpannable().getSpans(0, getText().length(), 2210 MoreImageSpan.class); 2211 if (tempMore.length > 0) { 2212 getSpannable().removeSpan(tempMore[0]); 2213 } 2214 DrawableRecipientChip[] recipients = getSortedRecipients(); 2215 2216 if (recipients == null || recipients.length <= CHIP_LIMIT) { 2217 mMoreChip = null; 2218 return; 2219 } 2220 Spannable spannable = getSpannable(); 2221 int numRecipients = recipients.length; 2222 int overage = numRecipients - CHIP_LIMIT; 2223 MoreImageSpan moreSpan = createMoreSpan(overage); 2224 mHiddenSpans = new ArrayList<DrawableRecipientChip>(); 2225 int totalReplaceStart = 0; 2226 int totalReplaceEnd = 0; 2227 Editable text = getText(); 2228 for (int i = numRecipients - overage; i < recipients.length; i++) { 2229 mHiddenSpans.add(recipients[i]); 2230 if (i == numRecipients - overage) { 2231 totalReplaceStart = spannable.getSpanStart(recipients[i]); 2232 } 2233 if (i == recipients.length - 1) { 2234 totalReplaceEnd = spannable.getSpanEnd(recipients[i]); 2235 } 2236 if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) { 2237 int spanStart = spannable.getSpanStart(recipients[i]); 2238 int spanEnd = spannable.getSpanEnd(recipients[i]); 2239 recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd)); 2240 } 2241 spannable.removeSpan(recipients[i]); 2242 } 2243 if (totalReplaceEnd < text.length()) { 2244 totalReplaceEnd = text.length(); 2245 } 2246 int end = Math.max(totalReplaceStart, totalReplaceEnd); 2247 int start = Math.min(totalReplaceStart, totalReplaceEnd); 2248 SpannableString chipText = new SpannableString(text.subSequence(start, end)); 2249 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2250 text.replace(start, end, chipText); 2251 mMoreChip = moreSpan; 2252 // If adding the +more chip goes over the limit, resize accordingly. 2253 if (!isPhoneQuery() && getLineCount() > mMaxLines) { 2254 setMaxLines(getLineCount()); 2255 } 2256 } 2257 2258 /** 2259 * Replace the more chip, if it exists, with all of the recipient chips it had 2260 * replaced when the RecipientEditTextView gains focus. 2261 */ 2262 // Visible for testing. removeMoreChip()2263 /*package*/ void removeMoreChip() { 2264 if (mMoreChip != null) { 2265 Spannable span = getSpannable(); 2266 span.removeSpan(mMoreChip); 2267 mMoreChip = null; 2268 // Re-add the spans that were hidden. 2269 if (mHiddenSpans != null && mHiddenSpans.size() > 0) { 2270 // Recreate each hidden span. 2271 DrawableRecipientChip[] recipients = getSortedRecipients(); 2272 // Start the search for tokens after the last currently visible 2273 // chip. 2274 if (recipients == null || recipients.length == 0) { 2275 return; 2276 } 2277 int end = span.getSpanEnd(recipients[recipients.length - 1]); 2278 Editable editable = getText(); 2279 for (DrawableRecipientChip chip : mHiddenSpans) { 2280 int chipStart; 2281 int chipEnd; 2282 String token; 2283 // Need to find the location of the chip, again. 2284 token = (String) chip.getOriginalText(); 2285 // As we find the matching recipient for the hidden spans, 2286 // reduce the size of the string we need to search. 2287 // That way, if there are duplicates, we always find the correct 2288 // recipient. 2289 chipStart = editable.toString().indexOf(token, end); 2290 end = chipEnd = Math.min(editable.length(), chipStart + token.length()); 2291 // Only set the span if we found a matching token. 2292 if (chipStart != -1) { 2293 editable.setSpan(chip, chipStart, chipEnd, 2294 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 2295 } 2296 } 2297 mHiddenSpans.clear(); 2298 } 2299 } 2300 } 2301 2302 /** 2303 * Show specified chip as selected. If the RecipientChip is just an email address, 2304 * selecting the chip will take the contents of the chip and place it at 2305 * the end of the RecipientEditTextView for inline editing. If the 2306 * RecipientChip is a complete contact, then selecting the chip 2307 * will show a popup window with the address in use highlighted and any other 2308 * alternate addresses for the contact. 2309 * @param currentChip Chip to select. 2310 */ selectChip(DrawableRecipientChip currentChip)2311 private void selectChip(DrawableRecipientChip currentChip) { 2312 if (shouldShowEditableText(currentChip)) { 2313 CharSequence text = currentChip.getValue(); 2314 Editable editable = getText(); 2315 Spannable spannable = getSpannable(); 2316 int spanStart = spannable.getSpanStart(currentChip); 2317 int spanEnd = spannable.getSpanEnd(currentChip); 2318 spannable.removeSpan(currentChip); 2319 // Don't need leading space if it's the only chip 2320 if (spanEnd - spanStart == editable.length() - 1) { 2321 spanEnd++; 2322 } 2323 editable.delete(spanStart, spanEnd); 2324 setCursorVisible(true); 2325 setSelection(editable.length()); 2326 editable.append(text); 2327 mSelectedChip = constructChipSpan( 2328 RecipientEntry.constructFakeEntry((String) text, isValid(text.toString()))); 2329 2330 /* 2331 * Because chip is destroyed and converted into an editable text, we call 2332 * {@link RecipientChipDeletedListener#onRecipientChipDeleted}. For the cases where 2333 * editable text is not shown (i.e. chip is in user's contact list), chip is focused 2334 * and below callback is not called. 2335 */ 2336 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2337 mRecipientChipDeletedListener.onRecipientChipDeleted(currentChip.getEntry()); 2338 } 2339 } else { 2340 final boolean showAddress = 2341 currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT || 2342 getAdapter().forceShowAddress(); 2343 if (showAddress && mNoChipMode) { 2344 return; 2345 } 2346 2347 if (isTouchExplorationEnabled()) { 2348 // The chips cannot be touch-explored. However, doing a double-tap results in 2349 // the popup being shown for the last chip, which is of no value. 2350 return; 2351 } 2352 2353 mSelectedChip = currentChip; 2354 setSelection(getText().getSpanEnd(mSelectedChip)); 2355 setCursorVisible(false); 2356 2357 if (showAddress) { 2358 showAddress(currentChip, mAddressPopup); 2359 } else { 2360 showAlternates(currentChip, mAlternatesPopup); 2361 } 2362 } 2363 } 2364 isTouchExplorationEnabled()2365 private boolean isTouchExplorationEnabled() { 2366 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 2367 return false; 2368 } 2369 2370 final AccessibilityManager accessibilityManager = (AccessibilityManager) 2371 getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); 2372 return accessibilityManager.isTouchExplorationEnabled(); 2373 } 2374 shouldShowEditableText(DrawableRecipientChip currentChip)2375 private boolean shouldShowEditableText(DrawableRecipientChip currentChip) { 2376 long contactId = currentChip.getContactId(); 2377 return contactId == RecipientEntry.INVALID_CONTACT 2378 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 2379 } 2380 showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup)2381 private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup) { 2382 if (!mAttachedToWindow) { 2383 return; 2384 } 2385 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 2386 int bottomOffset = calculateOffsetFromBottomToTop(line); 2387 // Align the alternates popup with the left side of the View, 2388 // regardless of the position of the chip tapped. 2389 popup.setAnchorView((mAlternatePopupAnchor != null) ? mAlternatePopupAnchor : this); 2390 popup.setVerticalOffset(bottomOffset); 2391 popup.setAdapter(createSingleAddressAdapter(currentChip)); 2392 popup.setOnItemClickListener(new OnItemClickListener() { 2393 @Override 2394 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 2395 unselectChip(currentChip); 2396 popup.dismiss(); 2397 } 2398 }); 2399 popup.show(); 2400 ListView listView = popup.getListView(); 2401 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 2402 listView.setItemChecked(0, true); 2403 } 2404 2405 /** 2406 * Remove selection from this chip. Unselecting a RecipientChip will render 2407 * the chip without a delete icon and with an unfocused background. This is 2408 * called when the RecipientChip no longer has focus. 2409 */ unselectChip(DrawableRecipientChip chip)2410 private void unselectChip(DrawableRecipientChip chip) { 2411 int start = getChipStart(chip); 2412 int end = getChipEnd(chip); 2413 Editable editable = getText(); 2414 mSelectedChip = null; 2415 if (start == -1 || end == -1) { 2416 Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing"); 2417 setSelection(editable.length()); 2418 commitDefault(); 2419 } else { 2420 getSpannable().removeSpan(chip); 2421 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 2422 editable.removeSpan(chip); 2423 try { 2424 if (!mNoChipMode) { 2425 editable.setSpan(constructChipSpan(chip.getEntry()), 2426 start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2427 } 2428 } catch (NullPointerException e) { 2429 Log.e(TAG, e.getMessage(), e); 2430 } 2431 } 2432 setCursorVisible(true); 2433 setSelection(editable.length()); 2434 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 2435 mAlternatesPopup.dismiss(); 2436 } 2437 } 2438 2439 @Override onChipDelete()2440 public void onChipDelete() { 2441 if (mSelectedChip != null) { 2442 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2443 mRecipientChipDeletedListener.onRecipientChipDeleted(mSelectedChip.getEntry()); 2444 } 2445 removeChip(mSelectedChip); 2446 } 2447 dismissPopups(); 2448 } 2449 2450 @Override onPermissionRequestDismissed()2451 public void onPermissionRequestDismissed() { 2452 if (mPermissionsRequestItemClickedListener != null) { 2453 mPermissionsRequestItemClickedListener.onPermissionRequestDismissed(); 2454 } 2455 dismissDropDown(); 2456 } 2457 dismissPopups()2458 private void dismissPopups() { 2459 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 2460 mAlternatesPopup.dismiss(); 2461 } 2462 if (mAddressPopup != null && mAddressPopup.isShowing()) { 2463 mAddressPopup.dismiss(); 2464 } 2465 setSelection(getText().length()); 2466 } 2467 2468 /** 2469 * Remove the chip and any text associated with it from the RecipientEditTextView. 2470 */ 2471 // Visible for testing. removeChip(DrawableRecipientChip chip)2472 /* package */void removeChip(DrawableRecipientChip chip) { 2473 Spannable spannable = getSpannable(); 2474 int spanStart = spannable.getSpanStart(chip); 2475 int spanEnd = spannable.getSpanEnd(chip); 2476 Editable text = getText(); 2477 int toDelete = spanEnd; 2478 boolean wasSelected = chip == mSelectedChip; 2479 // Clear that there is a selected chip before updating any text. 2480 if (wasSelected) { 2481 mSelectedChip = null; 2482 } 2483 // Always remove trailing spaces when removing a chip. 2484 while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') { 2485 toDelete++; 2486 } 2487 spannable.removeSpan(chip); 2488 if (spanStart >= 0 && toDelete > 0) { 2489 text.delete(spanStart, toDelete); 2490 } 2491 if (wasSelected) { 2492 clearSelectedChip(); 2493 } 2494 } 2495 2496 /** 2497 * Replace this currently selected chip with a new chip 2498 * that uses the contact data provided. 2499 */ 2500 // Visible for testing. replaceChip(DrawableRecipientChip chip, RecipientEntry entry)2501 /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) { 2502 boolean wasSelected = chip == mSelectedChip; 2503 if (wasSelected) { 2504 mSelectedChip = null; 2505 } 2506 int start = getChipStart(chip); 2507 int end = getChipEnd(chip); 2508 getSpannable().removeSpan(chip); 2509 Editable editable = getText(); 2510 CharSequence chipText = createChip(entry); 2511 if (chipText != null) { 2512 if (start == -1 || end == -1) { 2513 Log.e(TAG, "The chip to replace does not exist but should."); 2514 editable.insert(0, chipText); 2515 } else { 2516 if (!TextUtils.isEmpty(chipText)) { 2517 // There may be a space to replace with this chip's new 2518 // associated space. Check for it 2519 int toReplace = end; 2520 while (toReplace >= 0 && toReplace < editable.length() 2521 && editable.charAt(toReplace) == ' ') { 2522 toReplace++; 2523 } 2524 editable.replace(start, toReplace, chipText); 2525 } 2526 } 2527 } 2528 setCursorVisible(true); 2529 if (wasSelected) { 2530 clearSelectedChip(); 2531 } 2532 } 2533 2534 /** 2535 * Handle click events for a chip. When a selected chip receives a click 2536 * event, see if that event was in the delete icon. If so, delete it. 2537 * Otherwise, unselect the chip. 2538 */ onClick(DrawableRecipientChip chip)2539 public void onClick(DrawableRecipientChip chip) { 2540 if (chip.isSelected()) { 2541 clearSelectedChip(); 2542 } 2543 } 2544 chipsPending()2545 private boolean chipsPending() { 2546 return mPendingChipsCount > 0 || (mHiddenSpans != null && mHiddenSpans.size() > 0); 2547 } 2548 2549 @Override removeTextChangedListener(TextWatcher watcher)2550 public void removeTextChangedListener(TextWatcher watcher) { 2551 mTextWatcher = null; 2552 super.removeTextChangedListener(watcher); 2553 } 2554 isValidEmailAddress(String input)2555 private boolean isValidEmailAddress(String input) { 2556 return !TextUtils.isEmpty(input) && mValidator != null && 2557 mValidator.isValid(input); 2558 } 2559 2560 private class RecipientTextWatcher implements TextWatcher { 2561 2562 @Override afterTextChanged(Editable s)2563 public void afterTextChanged(Editable s) { 2564 // If the text has been set to null or empty, make sure we remove 2565 // all the spans we applied. 2566 if (TextUtils.isEmpty(s)) { 2567 // Remove all the chips spans. 2568 Spannable spannable = getSpannable(); 2569 DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(), 2570 DrawableRecipientChip.class); 2571 for (DrawableRecipientChip chip : chips) { 2572 spannable.removeSpan(chip); 2573 } 2574 if (mMoreChip != null) { 2575 spannable.removeSpan(mMoreChip); 2576 } 2577 clearSelectedChip(); 2578 return; 2579 } 2580 // Get whether there are any recipients pending addition to the 2581 // view. If there are, don't do anything in the text watcher. 2582 if (chipsPending()) { 2583 return; 2584 } 2585 // If the user is editing a chip, don't clear it. 2586 if (mSelectedChip != null) { 2587 if (!isGeneratedContact(mSelectedChip)) { 2588 setCursorVisible(true); 2589 setSelection(getText().length()); 2590 clearSelectedChip(); 2591 } else { 2592 return; 2593 } 2594 } 2595 int length = s.length(); 2596 // Make sure there is content there to parse and that it is 2597 // not just the commit character. 2598 if (length > 1) { 2599 if (lastCharacterIsCommitCharacter(s)) { 2600 commitByCharacter(); 2601 return; 2602 } 2603 char last; 2604 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2605 int len = length() - 1; 2606 if (end != len) { 2607 last = s.charAt(end); 2608 } else { 2609 last = s.charAt(len); 2610 } 2611 if (last == COMMIT_CHAR_SPACE) { 2612 if (!isPhoneQuery()) { 2613 // Check if this is a valid email address. If it is, 2614 // commit it. 2615 String text = getText().toString(); 2616 int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2617 String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text, 2618 tokenStart)); 2619 if (isValidEmailAddress(sub)) { 2620 commitByCharacter(); 2621 } 2622 } 2623 } 2624 } 2625 } 2626 2627 @Override onTextChanged(CharSequence s, int start, int before, int count)2628 public void onTextChanged(CharSequence s, int start, int before, int count) { 2629 // The user deleted some text OR some text was replaced; check to 2630 // see if the insertion point is on a space 2631 // following a chip. 2632 if (before - count == 1) { 2633 // If the item deleted is a space, and the thing before the 2634 // space is a chip, delete the entire span. 2635 int selStart = getSelectionStart(); 2636 DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart, 2637 DrawableRecipientChip.class); 2638 if (repl.length > 0) { 2639 // There is a chip there! Just remove it. 2640 DrawableRecipientChip toDelete = repl[0]; 2641 Editable editable = getText(); 2642 // Add the separator token. 2643 int deleteStart = editable.getSpanStart(toDelete); 2644 int deleteEnd = editable.getSpanEnd(toDelete) + 1; 2645 if (deleteEnd > editable.length()) { 2646 deleteEnd = editable.length(); 2647 } 2648 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2649 mRecipientChipDeletedListener.onRecipientChipDeleted(toDelete.getEntry()); 2650 } 2651 editable.removeSpan(toDelete); 2652 editable.delete(deleteStart, deleteEnd); 2653 } 2654 } else if (count > before) { 2655 if (mSelectedChip != null 2656 && isGeneratedContact(mSelectedChip)) { 2657 if (lastCharacterIsCommitCharacter(s)) { 2658 commitByCharacter(); 2659 return; 2660 } 2661 } 2662 } 2663 } 2664 2665 @Override beforeTextChanged(CharSequence s, int start, int count, int after)2666 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 2667 // Do nothing. 2668 } 2669 } 2670 lastCharacterIsCommitCharacter(CharSequence s)2671 public boolean lastCharacterIsCommitCharacter(CharSequence s) { 2672 char last; 2673 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2674 int len = length() - 1; 2675 if (end != len) { 2676 last = s.charAt(end); 2677 } else { 2678 last = s.charAt(len); 2679 } 2680 return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON; 2681 } 2682 isGeneratedContact(DrawableRecipientChip chip)2683 public boolean isGeneratedContact(DrawableRecipientChip chip) { 2684 long contactId = chip.getContactId(); 2685 return contactId == RecipientEntry.INVALID_CONTACT 2686 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 2687 } 2688 2689 /** 2690 * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}. 2691 */ 2692 // Visible for testing. handlePasteClip(ClipData clip)2693 void handlePasteClip(ClipData clip) { 2694 if (clip == null) { 2695 // Do nothing. 2696 return; 2697 } 2698 2699 final ClipDescription clipDesc = clip.getDescription(); 2700 boolean containsSupportedType = clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || 2701 clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML); 2702 if (!containsSupportedType) { 2703 return; 2704 } 2705 2706 removeTextChangedListener(mTextWatcher); 2707 2708 final ClipDescription clipDescription = clip.getDescription(); 2709 for (int i = 0; i < clip.getItemCount(); i++) { 2710 final String mimeType = clipDescription.getMimeType(i); 2711 final boolean supportedType = ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType) || 2712 ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType); 2713 if (!supportedType) { 2714 // Only plain text and html can be pasted. 2715 continue; 2716 } 2717 2718 final CharSequence pastedItem = clip.getItemAt(i).getText(); 2719 if (!TextUtils.isEmpty(pastedItem)) { 2720 final Editable editable = getText(); 2721 final int start = getSelectionStart(); 2722 final int end = getSelectionEnd(); 2723 if (start < 0 || end < 1) { 2724 // No selection. 2725 editable.append(pastedItem); 2726 } else if (start == end) { 2727 // Insert at position. 2728 editable.insert(start, pastedItem); 2729 } else { 2730 editable.append(pastedItem, start, end); 2731 } 2732 handlePasteAndReplace(); 2733 } 2734 } 2735 2736 mHandler.post(mAddTextWatcher); 2737 } 2738 2739 @Override onTextContextMenuItem(int id)2740 public boolean onTextContextMenuItem(int id) { 2741 if (id == android.R.id.paste) { 2742 ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService( 2743 Context.CLIPBOARD_SERVICE); 2744 handlePasteClip(clipboard.getPrimaryClip()); 2745 return true; 2746 } 2747 return super.onTextContextMenuItem(id); 2748 } 2749 handlePasteAndReplace()2750 private void handlePasteAndReplace() { 2751 ArrayList<DrawableRecipientChip> created = handlePaste(); 2752 if (created != null && created.size() > 0) { 2753 // Perform reverse lookups on the pasted contacts. 2754 IndividualReplacementTask replace = new IndividualReplacementTask(); 2755 replace.execute(created); 2756 } 2757 } 2758 2759 // Visible for testing. handlePaste()2760 /* package */ArrayList<DrawableRecipientChip> handlePaste() { 2761 String text = getText().toString(); 2762 int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2763 String lastAddress = text.substring(originalTokenStart); 2764 int tokenStart = originalTokenStart; 2765 int prevTokenStart = 0; 2766 DrawableRecipientChip findChip = null; 2767 ArrayList<DrawableRecipientChip> created = new ArrayList<DrawableRecipientChip>(); 2768 if (tokenStart != 0) { 2769 // There are things before this! 2770 while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) { 2771 prevTokenStart = tokenStart; 2772 tokenStart = mTokenizer.findTokenStart(text, tokenStart); 2773 findChip = findChip(tokenStart); 2774 if (tokenStart == originalTokenStart && findChip == null) { 2775 break; 2776 } 2777 } 2778 if (tokenStart != originalTokenStart) { 2779 if (findChip != null) { 2780 tokenStart = prevTokenStart; 2781 } 2782 int tokenEnd; 2783 DrawableRecipientChip createdChip; 2784 while (tokenStart < originalTokenStart) { 2785 tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(), 2786 tokenStart)); 2787 commitChip(tokenStart, tokenEnd, getText()); 2788 createdChip = findChip(tokenStart); 2789 if (createdChip == null) { 2790 break; 2791 } 2792 // +1 for the space at the end. 2793 tokenStart = getSpannable().getSpanEnd(createdChip) + 1; 2794 created.add(createdChip); 2795 } 2796 } 2797 } 2798 // Take a look at the last token. If the token has been completed with a 2799 // commit character, create a chip. 2800 if (isCompletedToken(lastAddress)) { 2801 Editable editable = getText(); 2802 tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart); 2803 commitChip(tokenStart, editable.length(), editable); 2804 created.add(findChip(tokenStart)); 2805 } 2806 return created; 2807 } 2808 2809 // Visible for testing. movePastTerminators(int tokenEnd)2810 /* package */int movePastTerminators(int tokenEnd) { 2811 if (tokenEnd >= length()) { 2812 return tokenEnd; 2813 } 2814 char atEnd = getText().toString().charAt(tokenEnd); 2815 if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) { 2816 tokenEnd++; 2817 } 2818 // This token had not only an end token character, but also a space 2819 // separating it from the next token. 2820 if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') { 2821 tokenEnd++; 2822 } 2823 return tokenEnd; 2824 } 2825 2826 private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> { createFreeChip(RecipientEntry entry)2827 private DrawableRecipientChip createFreeChip(RecipientEntry entry) { 2828 try { 2829 if (mNoChipMode) { 2830 return null; 2831 } 2832 return constructChipSpan(entry); 2833 } catch (NullPointerException e) { 2834 Log.e(TAG, e.getMessage(), e); 2835 return null; 2836 } 2837 } 2838 2839 @Override onPreExecute()2840 protected void onPreExecute() { 2841 // Ensure everything is in chip-form already, so we don't have text that slowly gets 2842 // replaced 2843 final List<DrawableRecipientChip> originalRecipients = 2844 new ArrayList<DrawableRecipientChip>(); 2845 final DrawableRecipientChip[] existingChips = getSortedRecipients(); 2846 Collections.addAll(originalRecipients, existingChips); 2847 if (mHiddenSpans != null) { 2848 originalRecipients.addAll(mHiddenSpans); 2849 } 2850 2851 final List<DrawableRecipientChip> replacements = 2852 new ArrayList<DrawableRecipientChip>(originalRecipients.size()); 2853 2854 for (final DrawableRecipientChip chip : originalRecipients) { 2855 if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId()) 2856 && getSpannable().getSpanStart(chip) != -1) { 2857 replacements.add(createFreeChip(chip.getEntry())); 2858 } else { 2859 replacements.add(null); 2860 } 2861 } 2862 2863 processReplacements(originalRecipients, replacements); 2864 } 2865 2866 @Override doInBackground(Void... params)2867 protected Void doInBackground(Void... params) { 2868 if (mIndividualReplacements != null) { 2869 mIndividualReplacements.cancel(true); 2870 } 2871 // For each chip in the list, look up the matching contact. 2872 // If there is a match, replace that chip with the matching 2873 // chip. 2874 final ArrayList<DrawableRecipientChip> recipients = 2875 new ArrayList<DrawableRecipientChip>(); 2876 DrawableRecipientChip[] existingChips = getSortedRecipients(); 2877 Collections.addAll(recipients, existingChips); 2878 if (mHiddenSpans != null) { 2879 recipients.addAll(mHiddenSpans); 2880 } 2881 ArrayList<String> addresses = new ArrayList<String>(); 2882 for (DrawableRecipientChip chip : recipients) { 2883 if (chip != null) { 2884 addresses.add(createAddressText(chip.getEntry())); 2885 } 2886 } 2887 final BaseRecipientAdapter adapter = getAdapter(); 2888 adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { 2889 @Override 2890 public void matchesFound(Map<String, RecipientEntry> entries) { 2891 final ArrayList<DrawableRecipientChip> replacements = 2892 new ArrayList<DrawableRecipientChip>(); 2893 for (final DrawableRecipientChip temp : recipients) { 2894 RecipientEntry entry = null; 2895 if (temp != null && RecipientEntry.isCreatedRecipient( 2896 temp.getEntry().getContactId()) 2897 && getSpannable().getSpanStart(temp) != -1) { 2898 // Replace this. 2899 entry = createValidatedEntry( 2900 entries.get(tokenizeAddress(temp.getEntry() 2901 .getDestination()))); 2902 } 2903 if (entry != null) { 2904 replacements.add(createFreeChip(entry)); 2905 } else { 2906 replacements.add(null); 2907 } 2908 } 2909 processReplacements(recipients, replacements); 2910 } 2911 2912 @Override 2913 public void matchesNotFound(final Set<String> unfoundAddresses) { 2914 final List<DrawableRecipientChip> replacements = 2915 new ArrayList<DrawableRecipientChip>(unfoundAddresses.size()); 2916 2917 for (final DrawableRecipientChip temp : recipients) { 2918 if (temp != null && RecipientEntry.isCreatedRecipient( 2919 temp.getEntry().getContactId()) 2920 && getSpannable().getSpanStart(temp) != -1) { 2921 if (unfoundAddresses.contains( 2922 temp.getEntry().getDestination())) { 2923 replacements.add(createFreeChip(temp.getEntry())); 2924 } else { 2925 replacements.add(null); 2926 } 2927 } else { 2928 replacements.add(null); 2929 } 2930 } 2931 2932 processReplacements(recipients, replacements); 2933 } 2934 }); 2935 return null; 2936 } 2937 processReplacements(final List<DrawableRecipientChip> recipients, final List<DrawableRecipientChip> replacements)2938 private void processReplacements(final List<DrawableRecipientChip> recipients, 2939 final List<DrawableRecipientChip> replacements) { 2940 if (replacements != null && replacements.size() > 0) { 2941 final Runnable runnable = new Runnable() { 2942 @Override 2943 public void run() { 2944 final Editable text = new SpannableStringBuilder(getText()); 2945 int i = 0; 2946 for (final DrawableRecipientChip chip : recipients) { 2947 final DrawableRecipientChip replacement = replacements.get(i); 2948 if (replacement != null) { 2949 final RecipientEntry oldEntry = chip.getEntry(); 2950 final RecipientEntry newEntry = replacement.getEntry(); 2951 final boolean isBetter = 2952 RecipientAlternatesAdapter.getBetterRecipient( 2953 oldEntry, newEntry) == newEntry; 2954 2955 if (isBetter) { 2956 // Find the location of the chip in the text currently shown. 2957 final int start = text.getSpanStart(chip); 2958 if (start != -1) { 2959 // Replacing the entirety of what the chip represented, 2960 // including the extra space dividing it from other chips. 2961 final int end = 2962 Math.min(text.getSpanEnd(chip) + 1, text.length()); 2963 text.removeSpan(chip); 2964 // Make sure we always have just 1 space at the end to 2965 // separate this chip from the next chip. 2966 final SpannableString displayText = 2967 new SpannableString(createAddressText( 2968 replacement.getEntry()).trim() + " "); 2969 displayText.setSpan(replacement, 0, 2970 displayText.length() - 1, 2971 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2972 // Replace the old text we found with with the new display 2973 // text, which now may also contain the display name of the 2974 // recipient. 2975 text.replace(start, end, displayText); 2976 replacement.setOriginalText(displayText.toString()); 2977 replacements.set(i, null); 2978 2979 recipients.set(i, replacement); 2980 } 2981 } 2982 } 2983 i++; 2984 } 2985 setText(text); 2986 } 2987 }; 2988 2989 if (Looper.myLooper() == Looper.getMainLooper()) { 2990 runnable.run(); 2991 } else { 2992 mHandler.post(runnable); 2993 } 2994 } 2995 } 2996 } 2997 2998 private class IndividualReplacementTask 2999 extends AsyncTask<ArrayList<DrawableRecipientChip>, Void, Void> { 3000 @Override doInBackground(ArrayList<DrawableRecipientChip>.... params)3001 protected Void doInBackground(ArrayList<DrawableRecipientChip>... params) { 3002 // For each chip in the list, look up the matching contact. 3003 // If there is a match, replace that chip with the matching 3004 // chip. 3005 final ArrayList<DrawableRecipientChip> originalRecipients = params[0]; 3006 ArrayList<String> addresses = new ArrayList<String>(); 3007 for (DrawableRecipientChip chip : originalRecipients) { 3008 if (chip != null) { 3009 addresses.add(createAddressText(chip.getEntry())); 3010 } 3011 } 3012 final BaseRecipientAdapter adapter = getAdapter(); 3013 adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { 3014 3015 @Override 3016 public void matchesFound(Map<String, RecipientEntry> entries) { 3017 for (final DrawableRecipientChip temp : originalRecipients) { 3018 if (RecipientEntry.isCreatedRecipient(temp.getEntry() 3019 .getContactId()) 3020 && getSpannable().getSpanStart(temp) != -1) { 3021 // Replace this. 3022 final RecipientEntry entry = createValidatedEntry(entries 3023 .get(tokenizeAddress(temp.getEntry().getDestination()) 3024 .toLowerCase())); 3025 if (entry != null) { 3026 mHandler.post(new Runnable() { 3027 @Override 3028 public void run() { 3029 replaceChip(temp, entry); 3030 } 3031 }); 3032 } 3033 } 3034 } 3035 } 3036 3037 @Override 3038 public void matchesNotFound(final Set<String> unfoundAddresses) { 3039 // No action required 3040 } 3041 }); 3042 return null; 3043 } 3044 } 3045 3046 3047 /** 3048 * MoreImageSpan is a simple class created for tracking the existence of a 3049 * more chip across activity restarts/ 3050 */ 3051 private class MoreImageSpan extends ReplacementDrawableSpan { MoreImageSpan(Drawable b)3052 public MoreImageSpan(Drawable b) { 3053 super(b); 3054 setExtraMargin(mLineSpacingExtra); 3055 } 3056 } 3057 3058 @Override onDown(MotionEvent e)3059 public boolean onDown(MotionEvent e) { 3060 return false; 3061 } 3062 3063 @Override onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)3064 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { 3065 // Do nothing. 3066 return false; 3067 } 3068 3069 @Override onLongPress(MotionEvent event)3070 public void onLongPress(MotionEvent event) { 3071 if (mSelectedChip != null) { 3072 return; 3073 } 3074 float x = event.getX(); 3075 float y = event.getY(); 3076 final int offset = putOffsetInRange(x, y); 3077 DrawableRecipientChip currentChip = findChip(offset); 3078 if (currentChip != null) { 3079 if (mDragEnabled) { 3080 // Start drag-and-drop for the selected chip. 3081 startDrag(currentChip); 3082 } else { 3083 // Copy the selected chip email address. 3084 showCopyDialog(currentChip.getEntry().getDestination()); 3085 } 3086 } 3087 } 3088 3089 // The following methods are used to provide some functionality on older versions of Android 3090 // These methods were copied out of JB MR2's TextView 3091 ///////////////////////////////////////////////// supportGetOffsetForPosition(float x, float y)3092 private int supportGetOffsetForPosition(float x, float y) { 3093 if (getLayout() == null) return -1; 3094 final int line = supportGetLineAtCoordinate(y); 3095 return supportGetOffsetAtCoordinate(line, x); 3096 } 3097 supportConvertToLocalHorizontalCoordinate(float x)3098 private float supportConvertToLocalHorizontalCoordinate(float x) { 3099 x -= getTotalPaddingLeft(); 3100 // Clamp the position to inside of the view. 3101 x = Math.max(0.0f, x); 3102 x = Math.min(getWidth() - getTotalPaddingRight() - 1, x); 3103 x += getScrollX(); 3104 return x; 3105 } 3106 supportGetLineAtCoordinate(float y)3107 private int supportGetLineAtCoordinate(float y) { 3108 y -= getTotalPaddingLeft(); 3109 // Clamp the position to inside of the view. 3110 y = Math.max(0.0f, y); 3111 y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y); 3112 y += getScrollY(); 3113 return getLayout().getLineForVertical((int) y); 3114 } 3115 supportGetOffsetAtCoordinate(int line, float x)3116 private int supportGetOffsetAtCoordinate(int line, float x) { 3117 x = supportConvertToLocalHorizontalCoordinate(x); 3118 return getLayout().getOffsetForHorizontal(line, x); 3119 } 3120 ///////////////////////////////////////////////// 3121 3122 /** 3123 * Enables drag-and-drop for chips. 3124 */ enableDrag()3125 public void enableDrag() { 3126 mDragEnabled = true; 3127 } 3128 3129 /** 3130 * Starts drag-and-drop for the selected chip. 3131 */ startDrag(DrawableRecipientChip currentChip)3132 private void startDrag(DrawableRecipientChip currentChip) { 3133 String address = currentChip.getEntry().getDestination(); 3134 ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA); 3135 3136 // Start drag mode. 3137 startDrag(data, new RecipientChipShadow(currentChip), null, 0); 3138 3139 // Remove the current chip, so drag-and-drop will result in a move. 3140 // TODO (phamm): consider readd this chip if it's dropped outside a target. 3141 removeChip(currentChip); 3142 } 3143 3144 /** 3145 * Handles drag event. 3146 */ 3147 @Override onDragEvent(@onNull DragEvent event)3148 public boolean onDragEvent(@NonNull DragEvent event) { 3149 switch (event.getAction()) { 3150 case DragEvent.ACTION_DRAG_STARTED: 3151 // Only handle plain text drag and drop. 3152 return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); 3153 case DragEvent.ACTION_DRAG_ENTERED: 3154 requestFocus(); 3155 return true; 3156 case DragEvent.ACTION_DROP: 3157 handlePasteClip(event.getClipData()); 3158 return true; 3159 } 3160 return false; 3161 } 3162 3163 /** 3164 * Drag shadow for a {@link DrawableRecipientChip}. 3165 */ 3166 private final class RecipientChipShadow extends DragShadowBuilder { 3167 private final DrawableRecipientChip mChip; 3168 RecipientChipShadow(DrawableRecipientChip chip)3169 public RecipientChipShadow(DrawableRecipientChip chip) { 3170 mChip = chip; 3171 } 3172 3173 @Override onProvideShadowMetrics(@onNull Point shadowSize, @NonNull Point shadowTouchPoint)3174 public void onProvideShadowMetrics(@NonNull Point shadowSize, 3175 @NonNull Point shadowTouchPoint) { 3176 Rect rect = mChip.getBounds(); 3177 shadowSize.set(rect.width(), rect.height()); 3178 shadowTouchPoint.set(rect.centerX(), rect.centerY()); 3179 } 3180 3181 @Override onDrawShadow(@onNull Canvas canvas)3182 public void onDrawShadow(@NonNull Canvas canvas) { 3183 mChip.draw(canvas); 3184 } 3185 } 3186 showCopyDialog(final String address)3187 private void showCopyDialog(final String address) { 3188 final Context context = getContext(); 3189 if (!mAttachedToWindow || context == null || !(context instanceof Activity)) { 3190 return; 3191 } 3192 3193 final DialogFragment fragment = CopyDialog.newInstance(address); 3194 fragment.show(((Activity) context).getFragmentManager(), CopyDialog.TAG); 3195 } 3196 3197 @Override onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)3198 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { 3199 // Do nothing. 3200 return false; 3201 } 3202 3203 @Override onShowPress(MotionEvent e)3204 public void onShowPress(MotionEvent e) { 3205 // Do nothing. 3206 } 3207 3208 @Override onSingleTapUp(MotionEvent e)3209 public boolean onSingleTapUp(MotionEvent e) { 3210 // Do nothing. 3211 return false; 3212 } 3213 isPhoneQuery()3214 protected boolean isPhoneQuery() { 3215 return getAdapter() != null 3216 && getAdapter().getQueryType() == BaseRecipientAdapter.QUERY_TYPE_PHONE; 3217 } 3218 3219 @Override getAdapter()3220 public BaseRecipientAdapter getAdapter() { 3221 return (BaseRecipientAdapter) super.getAdapter(); 3222 } 3223 3224 /** 3225 * Append a new {@link RecipientEntry} to the end of the recipient chips, leaving any 3226 * unfinished text at the end. 3227 */ appendRecipientEntry(final RecipientEntry entry)3228 public void appendRecipientEntry(final RecipientEntry entry) { 3229 clearComposingText(); 3230 3231 final Editable editable = getText(); 3232 int chipInsertionPoint = 0; 3233 3234 // Find the end of last chip and see if there's any unchipified text. 3235 final DrawableRecipientChip[] recips = getSortedRecipients(); 3236 if (recips != null && recips.length > 0) { 3237 final DrawableRecipientChip last = recips[recips.length - 1]; 3238 // The chip will be inserted at the end of last chip + 1. All the unfinished text after 3239 // the insertion point will be kept untouched. 3240 chipInsertionPoint = editable.getSpanEnd(last) + 1; 3241 } 3242 3243 final CharSequence chip = createChip(entry); 3244 if (chip != null) { 3245 editable.insert(chipInsertionPoint, chip); 3246 } 3247 } 3248 3249 /** 3250 * Remove all chips matching the given RecipientEntry. 3251 */ removeRecipientEntry(final RecipientEntry entry)3252 public void removeRecipientEntry(final RecipientEntry entry) { 3253 final DrawableRecipientChip[] recips = getText() 3254 .getSpans(0, getText().length(), DrawableRecipientChip.class); 3255 3256 for (final DrawableRecipientChip recipient : recips) { 3257 final RecipientEntry existingEntry = recipient.getEntry(); 3258 if (existingEntry != null && existingEntry.isValid() && 3259 existingEntry.isSamePerson(entry)) { 3260 removeChip(recipient); 3261 } 3262 } 3263 } 3264 setAlternatePopupAnchor(View v)3265 public void setAlternatePopupAnchor(View v) { 3266 mAlternatePopupAnchor = v; 3267 } 3268 3269 @Override setVisibility(int visibility)3270 public void setVisibility(int visibility) { 3271 super.setVisibility(visibility); 3272 3273 if (visibility != GONE && mRequiresShrinkWhenNotGone) { 3274 mRequiresShrinkWhenNotGone = false; 3275 mHandler.post(mDelayedShrink); 3276 } 3277 } 3278 3279 private static class ChipBitmapContainer { 3280 Bitmap bitmap; 3281 // information used for positioning the loaded icon 3282 boolean loadIcon = true; 3283 float left; 3284 float top; 3285 float right; 3286 float bottom; 3287 } 3288 } 3289