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 and when not displaying photos. 800 boolean displayIcon = contact.isValid() && contact.shouldDisplayIcon(); 801 int iconWidth = displayIcon ? 802 height - backgroundPadding.top - backgroundPadding.bottom : 0; 803 float[] widths = new float[1]; 804 paint.getTextWidths(" ", widths); 805 CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint, 806 calculateAvailableWidth() - iconWidth - widths[0] - backgroundPadding.left 807 - backgroundPadding.right - indicatorPadding); 808 int textWidth = (int) paint.measureText(ellipsizedText, 0, ellipsizedText.length()); 809 810 // Chip start padding is the same as the end padding if there is no contact image. 811 final int startPadding = displayIcon ? mChipTextStartPadding : mChipTextEndPadding; 812 // Make sure there is a minimum chip width so the user can ALWAYS 813 // tap a chip without difficulty. 814 int width = Math.max(iconWidth * 2, textWidth + startPadding + mChipTextEndPadding 815 + iconWidth + backgroundPadding.left + backgroundPadding.right + indicatorPadding); 816 817 // Create the background of the chip. 818 result.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 819 final Canvas canvas = new Canvas(result.bitmap); 820 821 // Check if the background drawable is set via attr 822 if (overrideBackgroundDrawable != null) { 823 overrideBackgroundDrawable.setBounds(0, 0, width, height); 824 overrideBackgroundDrawable.draw(canvas); 825 } else { 826 // Draw the default chip background 827 mWorkPaint.reset(); 828 mWorkPaint.setColor(backgroundColor); 829 final float radius = height / 2; 830 canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius, 831 mWorkPaint); 832 } 833 834 // Draw the text vertically aligned 835 int textX = shouldPositionAvatarOnRight() ? 836 mChipTextEndPadding + backgroundPadding.left + indicatorPadding : 837 width - backgroundPadding.right - mChipTextEndPadding - textWidth - 838 indicatorPadding; 839 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), 840 textX, getTextYOffset(height), paint); 841 842 if (indicatorIcon != null) { 843 int indicatorX = shouldPositionAvatarOnRight() 844 ? backgroundPadding.left + mChipTextEndPadding 845 : width - backgroundPadding.right - indicatorIcon.getBounds().width() 846 - mChipTextEndPadding; 847 int indicatorY = height / 2 - indicatorIcon.getBounds().height() / 2; 848 indicatorIcon.getBounds().offsetTo(indicatorX, indicatorY); 849 indicatorIcon.draw(canvas); 850 } 851 852 // Set the variables that are needed to draw the icon bitmap once it's loaded 853 int iconX = shouldPositionAvatarOnRight() ? width - backgroundPadding.right - iconWidth : 854 backgroundPadding.left; 855 result.left = iconX; 856 result.top = backgroundPadding.top; 857 result.right = iconX + iconWidth; 858 result.bottom = height - backgroundPadding.bottom; 859 result.loadIcon = displayIcon; 860 861 return result; 862 } 863 864 /** 865 * Helper function that draws the loaded icon bitmap into the chips bitmap 866 */ drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon)867 private void drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon) { 868 final Canvas canvas = new Canvas(bitMapResult.bitmap); 869 final RectF src = new RectF(0, 0, icon.getWidth(), icon.getHeight()); 870 final RectF dst = new RectF(bitMapResult.left, bitMapResult.top, bitMapResult.right, 871 bitMapResult.bottom); 872 drawIconOnCanvas(icon, canvas, src, dst); 873 } 874 875 /** 876 * Returns true if the avatar should be positioned at the right edge of the chip. 877 * Takes into account both the set avatar position (start or end) as well as whether 878 * the layout direction is LTR or RTL. 879 */ shouldPositionAvatarOnRight()880 private boolean shouldPositionAvatarOnRight() { 881 final boolean isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 && 882 getLayoutDirection() == LAYOUT_DIRECTION_RTL; 883 final boolean assignedPosition = mAvatarPosition == AVATAR_POSITION_END; 884 // If in Rtl mode, the position should be flipped. 885 return isRtl ? !assignedPosition : assignedPosition; 886 } 887 888 /** 889 * Returns the avatar icon to use for this recipient entry. Returns null if we don't want to 890 * draw an icon for this recipient. 891 */ loadAvatarIcon(final RecipientEntry contact, final ChipBitmapContainer bitmapContainer)892 private void loadAvatarIcon(final RecipientEntry contact, 893 final ChipBitmapContainer bitmapContainer) { 894 // Don't draw photos for recipients that have been typed in OR generated on the fly. 895 long contactId = contact.getContactId(); 896 boolean drawPhotos = isPhoneQuery() ? 897 contactId != RecipientEntry.INVALID_CONTACT 898 : (contactId != RecipientEntry.INVALID_CONTACT 899 && contactId != RecipientEntry.GENERATED_CONTACT); 900 901 if (drawPhotos) { 902 final byte[] origPhotoBytes = contact.getPhotoBytes(); 903 // There may not be a photo yet if anything but the first contact address 904 // was selected. 905 if (origPhotoBytes == null) { 906 // TODO: cache this in the recipient entry? 907 getAdapter().fetchPhoto(contact, new PhotoManager.PhotoManagerCallback() { 908 @Override 909 public void onPhotoBytesPopulated() { 910 // Call through to the async version which will ensure 911 // proper threading. 912 onPhotoBytesAsynchronouslyPopulated(); 913 } 914 915 @Override 916 public void onPhotoBytesAsynchronouslyPopulated() { 917 final byte[] loadedPhotoBytes = contact.getPhotoBytes(); 918 final Bitmap icon = BitmapFactory.decodeByteArray(loadedPhotoBytes, 0, 919 loadedPhotoBytes.length); 920 tryDrawAndInvalidate(icon); 921 } 922 923 @Override 924 public void onPhotoBytesAsyncLoadFailed() { 925 // TODO: can the scaled down default photo be cached? 926 tryDrawAndInvalidate(mDefaultContactPhoto); 927 } 928 929 private void tryDrawAndInvalidate(Bitmap icon) { 930 drawIcon(bitmapContainer, icon); 931 // The caller might originated from a background task. However, if the 932 // background task has already completed, the view might be already drawn 933 // on the UI but the callback would happen on the background thread. 934 // So if we are on a background thread, post an invalidate call to the UI. 935 if (Looper.myLooper() == Looper.getMainLooper()) { 936 // The view might not redraw itself since it's loaded asynchronously 937 invalidate(); 938 } else { 939 post(new Runnable() { 940 @Override 941 public void run() { 942 invalidate(); 943 } 944 }); 945 } 946 } 947 }); 948 } else { 949 final Bitmap icon = BitmapFactory.decodeByteArray(origPhotoBytes, 0, 950 origPhotoBytes.length); 951 drawIcon(bitmapContainer, icon); 952 } 953 } 954 } 955 956 /** 957 * Get the background drawable for a RecipientChip. 958 */ 959 // Visible for testing. getChipBackground(RecipientEntry contact)960 /* package */Drawable getChipBackground(RecipientEntry contact) { 961 return contact.isValid() ? mChipBackground : mInvalidChipBackground; 962 } 963 getDefaultChipTextColor(RecipientEntry contact)964 private int getDefaultChipTextColor(RecipientEntry contact) { 965 return contact.isValid() ? mUnselectedChipTextColor : 966 getResources().getColor(android.R.color.black); 967 } 968 getDefaultChipBackgroundColor(RecipientEntry contact)969 private int getDefaultChipBackgroundColor(RecipientEntry contact) { 970 return contact.isValid() ? mUnselectedChipBackgroundColor : 971 getResources().getColor(R.color.chip_background_invalid); 972 } 973 974 /** 975 * Given a height, returns a Y offset that will draw the text in the middle of the height. 976 */ getTextYOffset(int height)977 protected float getTextYOffset(int height) { 978 return height - ((height - mTextHeight) / 2); 979 } 980 981 /** 982 * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination 983 * rectangle of the canvas. 984 */ drawIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst)985 protected void drawIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) { 986 final Matrix matrix = new Matrix(); 987 988 // Draw bitmap through shader first. 989 final BitmapShader shader = new BitmapShader(icon, TileMode.CLAMP, TileMode.CLAMP); 990 matrix.reset(); 991 992 // Fit bitmap to bounds. 993 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL); 994 995 shader.setLocalMatrix(matrix); 996 mWorkPaint.reset(); 997 mWorkPaint.setShader(shader); 998 mWorkPaint.setAntiAlias(true); 999 mWorkPaint.setFilterBitmap(true); 1000 mWorkPaint.setDither(true); 1001 canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f, mWorkPaint); 1002 1003 // Then draw the border. 1004 final float borderWidth = 1f; 1005 mWorkPaint.reset(); 1006 mWorkPaint.setColor(Color.TRANSPARENT); 1007 mWorkPaint.setStyle(Style.STROKE); 1008 mWorkPaint.setStrokeWidth(borderWidth); 1009 mWorkPaint.setAntiAlias(true); 1010 canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f - borderWidth / 2, 1011 mWorkPaint); 1012 1013 mWorkPaint.reset(); 1014 } 1015 constructChipSpan(RecipientEntry contact)1016 private DrawableRecipientChip constructChipSpan(RecipientEntry contact) { 1017 TextPaint paint = getPaint(); 1018 float defaultSize = paint.getTextSize(); 1019 int defaultColor = paint.getColor(); 1020 1021 Bitmap tmpBitmap = createChipBitmap(contact, paint); 1022 1023 // Pass the full text, un-ellipsized, to the chip. 1024 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 1025 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 1026 VisibleRecipientChip recipientChip = 1027 new VisibleRecipientChip(result, contact); 1028 recipientChip.setExtraMargin(mLineSpacingExtra); 1029 // Return text to the original size. 1030 paint.setTextSize(defaultSize); 1031 paint.setColor(defaultColor); 1032 return recipientChip; 1033 } 1034 1035 /** 1036 * Calculate the offset from bottom of the EditText to top of the provided line. 1037 */ calculateOffsetFromBottomToTop(int line)1038 private int calculateOffsetFromBottomToTop(int line) { 1039 return -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math 1040 .abs(getLineCount() - line)) + getPaddingBottom()); 1041 } 1042 1043 /** 1044 * Get the max amount of space a chip can take up. The formula takes into 1045 * account the width of the EditTextView, any view padding, and padding 1046 * that will be added to the chip. 1047 */ calculateAvailableWidth()1048 private float calculateAvailableWidth() { 1049 return getWidth() - getPaddingLeft() - getPaddingRight() - mChipTextStartPadding 1050 - mChipTextEndPadding; 1051 } 1052 1053 setChipDimensions(Context context, AttributeSet attrs)1054 private void setChipDimensions(Context context, AttributeSet attrs) { 1055 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0, 1056 0); 1057 Resources r = getContext().getResources(); 1058 1059 mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground); 1060 mInvalidChipBackground = a 1061 .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground); 1062 mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete); 1063 if (mChipDelete == null) { 1064 mChipDelete = r.getDrawable(R.drawable.ic_cancel_wht_24dp); 1065 } 1066 mChipTextStartPadding = mChipTextEndPadding 1067 = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1); 1068 if (mChipTextStartPadding == -1) { 1069 mChipTextStartPadding = mChipTextEndPadding = 1070 (int) r.getDimension(R.dimen.chip_padding); 1071 } 1072 // xml-overrides for each individual padding 1073 // TODO: add these to attr? 1074 int overridePadding = (int) r.getDimension(R.dimen.chip_padding_start); 1075 if (overridePadding >= 0) { 1076 mChipTextStartPadding = overridePadding; 1077 } 1078 overridePadding = (int) r.getDimension(R.dimen.chip_padding_end); 1079 if (overridePadding >= 0) { 1080 mChipTextEndPadding = overridePadding; 1081 } 1082 1083 mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture); 1084 1085 mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null); 1086 1087 mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1); 1088 if (mChipHeight == -1) { 1089 mChipHeight = r.getDimension(R.dimen.chip_height); 1090 } 1091 mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1); 1092 if (mChipFontSize == -1) { 1093 mChipFontSize = r.getDimension(R.dimen.chip_text_size); 1094 } 1095 mAvatarPosition = 1096 a.getInt(R.styleable.RecipientEditTextView_avatarPosition, AVATAR_POSITION_START); 1097 mDisableDelete = a.getBoolean(R.styleable.RecipientEditTextView_disableDelete, false); 1098 1099 mMaxLines = r.getInteger(R.integer.chips_max_lines); 1100 mLineSpacingExtra = r.getDimensionPixelOffset(R.dimen.line_spacing_extra); 1101 1102 mUnselectedChipTextColor = a.getColor( 1103 R.styleable.RecipientEditTextView_unselectedChipTextColor, 1104 r.getColor(android.R.color.black)); 1105 1106 mUnselectedChipBackgroundColor = a.getColor( 1107 R.styleable.RecipientEditTextView_unselectedChipBackgroundColor, 1108 r.getColor(R.color.chip_background)); 1109 1110 a.recycle(); 1111 } 1112 1113 // Visible for testing. setMoreItem(TextView moreItem)1114 /* package */ void setMoreItem(TextView moreItem) { 1115 mMoreItem = moreItem; 1116 } 1117 1118 1119 // Visible for testing. setChipBackground(Drawable chipBackground)1120 /* package */ void setChipBackground(Drawable chipBackground) { 1121 mChipBackground = chipBackground; 1122 } 1123 1124 // Visible for testing. setChipHeight(int height)1125 /* package */ void setChipHeight(int height) { 1126 mChipHeight = height; 1127 } 1128 getChipHeight()1129 public float getChipHeight() { 1130 return mChipHeight; 1131 } 1132 1133 /** Returns whether view is in no-chip or chip mode. */ isNoChipMode()1134 public boolean isNoChipMode() { 1135 return mNoChipMode; 1136 } 1137 1138 /** 1139 * Set whether to shrink the recipients field such that at most 1140 * one line of recipients chips are shown when the field loses 1141 * focus. By default, the number of displayed recipients will be 1142 * limited and a "more" chip will be shown when focus is lost. 1143 * @param shrink 1144 */ setOnFocusListShrinkRecipients(boolean shrink)1145 public void setOnFocusListShrinkRecipients(boolean shrink) { 1146 mShouldShrink = shrink; 1147 } 1148 1149 @Override onSizeChanged(int width, int height, int oldw, int oldh)1150 public void onSizeChanged(int width, int height, int oldw, int oldh) { 1151 super.onSizeChanged(width, height, oldw, oldh); 1152 if (width != 0 && height != 0) { 1153 if (mPendingChipsCount > 0) { 1154 postHandlePendingChips(); 1155 } else { 1156 checkChipWidths(); 1157 } 1158 } 1159 // Try to find the scroll view parent, if it exists. 1160 if (mScrollView == null && !mTriedGettingScrollView) { 1161 ViewParent parent = getParent(); 1162 while (parent != null && !(parent instanceof ScrollView)) { 1163 parent = parent.getParent(); 1164 } 1165 if (parent != null) { 1166 mScrollView = (ScrollView) parent; 1167 } 1168 mTriedGettingScrollView = true; 1169 } 1170 } 1171 postHandlePendingChips()1172 private void postHandlePendingChips() { 1173 mHandler.removeCallbacks(mHandlePendingChips); 1174 mHandler.post(mHandlePendingChips); 1175 } 1176 checkChipWidths()1177 private void checkChipWidths() { 1178 // Check the widths of the associated chips. 1179 DrawableRecipientChip[] chips = getSortedRecipients(); 1180 if (chips != null) { 1181 Rect bounds; 1182 for (DrawableRecipientChip chip : chips) { 1183 bounds = chip.getBounds(); 1184 if (getWidth() > 0 && bounds.right - bounds.left > 1185 getWidth() - getPaddingLeft() - getPaddingRight()) { 1186 // Need to redraw that chip. 1187 replaceChip(chip, chip.getEntry()); 1188 } 1189 } 1190 } 1191 } 1192 1193 // Visible for testing. handlePendingChips()1194 /*package*/ void handlePendingChips() { 1195 if (getViewWidth() <= 0) { 1196 // The widget has not been sized yet. 1197 // This will be called as a result of onSizeChanged 1198 // at a later point. 1199 return; 1200 } 1201 if (mPendingChipsCount <= 0) { 1202 return; 1203 } 1204 1205 synchronized (mPendingChips) { 1206 Editable editable = getText(); 1207 // Tokenize! 1208 if (mPendingChipsCount <= MAX_CHIPS_PARSED) { 1209 for (int i = 0; i < mPendingChips.size(); i++) { 1210 String current = mPendingChips.get(i); 1211 int tokenStart = editable.toString().indexOf(current); 1212 // Always leave a space at the end between tokens. 1213 int tokenEnd = tokenStart + current.length() - 1; 1214 if (tokenStart >= 0) { 1215 // When we have a valid token, include it with the token 1216 // to the left. 1217 if (tokenEnd < editable.length() - 2 1218 && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) { 1219 tokenEnd++; 1220 } 1221 createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT 1222 || !mShouldShrink); 1223 } 1224 mPendingChipsCount--; 1225 } 1226 sanitizeEnd(); 1227 } else { 1228 mNoChipMode = true; 1229 } 1230 1231 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0 1232 && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) { 1233 if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) { 1234 new RecipientReplacementTask().execute(); 1235 mTemporaryRecipients = null; 1236 } else { 1237 // Create the "more" chip 1238 mIndividualReplacements = new IndividualReplacementTask(); 1239 mIndividualReplacements.execute(new ArrayList<DrawableRecipientChip>( 1240 mTemporaryRecipients.subList(0, CHIP_LIMIT))); 1241 if (mTemporaryRecipients.size() > CHIP_LIMIT) { 1242 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>( 1243 mTemporaryRecipients.subList(CHIP_LIMIT, 1244 mTemporaryRecipients.size())); 1245 } else { 1246 mTemporaryRecipients = null; 1247 } 1248 createMoreChip(); 1249 } 1250 } else { 1251 // There are too many recipients to look up, so just fall back 1252 // to showing addresses for all of them. 1253 mTemporaryRecipients = null; 1254 createMoreChip(); 1255 } 1256 mPendingChipsCount = 0; 1257 mPendingChips.clear(); 1258 } 1259 } 1260 1261 // Visible for testing. getViewWidth()1262 /*package*/ int getViewWidth() { 1263 return getWidth(); 1264 } 1265 1266 /** 1267 * Remove any characters after the last valid chip. 1268 */ 1269 // Visible for testing. sanitizeEnd()1270 /*package*/ void sanitizeEnd() { 1271 // Don't sanitize while we are waiting for pending chips to complete. 1272 if (mPendingChipsCount > 0) { 1273 return; 1274 } 1275 // Find the last chip; eliminate any commit characters after it. 1276 DrawableRecipientChip[] chips = getSortedRecipients(); 1277 Spannable spannable = getSpannable(); 1278 if (chips != null && chips.length > 0) { 1279 int end; 1280 mMoreChip = getMoreChip(); 1281 if (mMoreChip != null) { 1282 end = spannable.getSpanEnd(mMoreChip); 1283 } else { 1284 end = getSpannable().getSpanEnd(getLastChip()); 1285 } 1286 Editable editable = getText(); 1287 int length = editable.length(); 1288 if (length > end) { 1289 // See what characters occur after that and eliminate them. 1290 if (Log.isLoggable(TAG, Log.DEBUG)) { 1291 Log.d(TAG, "There were extra characters after the last tokenizable entry." 1292 + editable); 1293 } 1294 editable.delete(end + 1, length); 1295 } 1296 } 1297 } 1298 1299 /** 1300 * Create a chip that represents just the email address of a recipient. At some later 1301 * point, this chip will be attached to a real contact entry, if one exists. 1302 */ 1303 // VisibleForTesting createReplacementChip(int tokenStart, int tokenEnd, Editable editable, boolean visible)1304 void createReplacementChip(int tokenStart, int tokenEnd, Editable editable, 1305 boolean visible) { 1306 if (alreadyHasChip(tokenStart, tokenEnd)) { 1307 // There is already a chip present at this location. 1308 // Don't recreate it. 1309 return; 1310 } 1311 String token = editable.toString().substring(tokenStart, tokenEnd); 1312 final String trimmedToken = token.trim(); 1313 int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA); 1314 if (commitCharIndex != -1 && commitCharIndex == trimmedToken.length() - 1) { 1315 token = trimmedToken.substring(0, trimmedToken.length() - 1); 1316 } 1317 RecipientEntry entry = createTokenizedEntry(token); 1318 if (entry != null) { 1319 DrawableRecipientChip chip = null; 1320 try { 1321 if (!mNoChipMode) { 1322 chip = visible ? constructChipSpan(entry) : new InvisibleRecipientChip(entry); 1323 } 1324 } catch (NullPointerException e) { 1325 Log.e(TAG, e.getMessage(), e); 1326 } 1327 editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1328 // Add this chip to the list of entries "to replace" 1329 if (chip != null) { 1330 if (mTemporaryRecipients == null) { 1331 mTemporaryRecipients = new ArrayList<DrawableRecipientChip>(); 1332 } 1333 chip.setOriginalText(token); 1334 mTemporaryRecipients.add(chip); 1335 } 1336 } 1337 } 1338 1339 // VisibleForTesting createTokenizedEntry(final String token)1340 RecipientEntry createTokenizedEntry(final String token) { 1341 if (TextUtils.isEmpty(token)) { 1342 return null; 1343 } 1344 if (isPhoneQuery() && PhoneUtil.isPhoneNumber(token)) { 1345 return RecipientEntry.constructFakePhoneEntry(token, true); 1346 } 1347 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token); 1348 boolean isValid = isValid(token); 1349 if (isValid && tokens != null && tokens.length > 0) { 1350 // If we can get a name from tokenizing, then generate an entry from 1351 // this. 1352 String display = tokens[0].getName(); 1353 if (!TextUtils.isEmpty(display)) { 1354 return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(), 1355 isValid); 1356 } else { 1357 display = tokens[0].getAddress(); 1358 if (!TextUtils.isEmpty(display)) { 1359 return RecipientEntry.constructFakeEntry(display, isValid); 1360 } 1361 } 1362 } 1363 // Unable to validate the token or to create a valid token from it. 1364 // Just create a chip the user can edit. 1365 String validatedToken = null; 1366 if (mValidator != null && !isValid) { 1367 // Try fixing up the entry using the validator. 1368 validatedToken = mValidator.fixText(token).toString(); 1369 if (!TextUtils.isEmpty(validatedToken)) { 1370 if (validatedToken.contains(token)) { 1371 // protect against the case of a validator with a null 1372 // domain, 1373 // which doesn't add a domain to the token 1374 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken); 1375 if (tokenized.length > 0) { 1376 validatedToken = tokenized[0].getAddress(); 1377 isValid = true; 1378 } 1379 } else { 1380 // We ran into a case where the token was invalid and 1381 // removed 1382 // by the validator. In this case, just use the original 1383 // token 1384 // and let the user sort out the error chip. 1385 validatedToken = null; 1386 isValid = false; 1387 } 1388 } 1389 } 1390 // Otherwise, fallback to just creating an editable email address chip. 1391 return RecipientEntry.constructFakeEntry( 1392 !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid); 1393 } 1394 isValid(String text)1395 private boolean isValid(String text) { 1396 return mValidator == null ? true : mValidator.isValid(text); 1397 } 1398 tokenizeAddress(String destination)1399 private static String tokenizeAddress(String destination) { 1400 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination); 1401 if (tokens != null && tokens.length > 0) { 1402 return tokens[0].getAddress(); 1403 } 1404 return destination; 1405 } 1406 1407 @Override setTokenizer(Tokenizer tokenizer)1408 public void setTokenizer(Tokenizer tokenizer) { 1409 mTokenizer = tokenizer; 1410 super.setTokenizer(mTokenizer); 1411 } 1412 1413 @Override setValidator(Validator validator)1414 public void setValidator(Validator validator) { 1415 mValidator = validator; 1416 super.setValidator(validator); 1417 } 1418 1419 /** 1420 * We cannot use the default mechanism for replaceText. Instead, 1421 * we override onItemClickListener so we can get all the associated 1422 * contact information including display text, address, and id. 1423 */ 1424 @Override replaceText(CharSequence text)1425 protected void replaceText(CharSequence text) { 1426 return; 1427 } 1428 1429 /** 1430 * Dismiss any selected chips when the back key is pressed. 1431 */ 1432 @Override onKeyPreIme(int keyCode, @NonNull KeyEvent event)1433 public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) { 1434 if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) { 1435 clearSelectedChip(); 1436 return true; 1437 } 1438 return super.onKeyPreIme(keyCode, event); 1439 } 1440 1441 /** 1442 * Monitor key presses in this view to see if the user types 1443 * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. 1444 * If the user has entered text that has contact matches and types 1445 * a commit key, create a chip from the topmost matching contact. 1446 * If the user has entered text that has no contact matches and types 1447 * a commit key, then create a chip from the text they have entered. 1448 */ 1449 @Override onKeyUp(int keyCode, @NonNull KeyEvent event)1450 public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { 1451 switch (keyCode) { 1452 case KeyEvent.KEYCODE_TAB: 1453 if (event.hasNoModifiers()) { 1454 if (mSelectedChip != null) { 1455 clearSelectedChip(); 1456 } else { 1457 commitDefault(); 1458 } 1459 } 1460 break; 1461 } 1462 return super.onKeyUp(keyCode, event); 1463 } 1464 focusNext()1465 private boolean focusNext() { 1466 View next = focusSearch(View.FOCUS_DOWN); 1467 if (next != null) { 1468 next.requestFocus(); 1469 return true; 1470 } 1471 return false; 1472 } 1473 1474 /** 1475 * Create a chip from the default selection. If the popup is showing, the 1476 * default is the selected item (if one is selected), or the first item, in the popup 1477 * suggestions list. Otherwise, it is whatever the user had typed in. End represents where the 1478 * tokenizer should search for a token to turn into a chip. 1479 * @return If a chip was created from a real contact. 1480 */ commitDefault()1481 private boolean commitDefault() { 1482 // If there is no tokenizer, don't try to commit. 1483 if (mTokenizer == null) { 1484 return false; 1485 } 1486 Editable editable = getText(); 1487 int end = getSelectionEnd(); 1488 int start = mTokenizer.findTokenStart(editable, end); 1489 1490 if (shouldCreateChip(start, end)) { 1491 int whatEnd = mTokenizer.findTokenEnd(getText(), start); 1492 // In the middle of chip; treat this as an edit 1493 // and commit the whole token. 1494 whatEnd = movePastTerminators(whatEnd); 1495 if (whatEnd != getSelectionEnd()) { 1496 handleEdit(start, whatEnd); 1497 return true; 1498 } 1499 return commitChip(start, end , editable); 1500 } 1501 return false; 1502 } 1503 commitByCharacter()1504 private void commitByCharacter() { 1505 // We can't possibly commit by character if we can't tokenize. 1506 if (mTokenizer == null) { 1507 return; 1508 } 1509 Editable editable = getText(); 1510 int end = getSelectionEnd(); 1511 int start = mTokenizer.findTokenStart(editable, end); 1512 if (shouldCreateChip(start, end)) { 1513 commitChip(start, end, editable); 1514 } 1515 setSelection(getText().length()); 1516 } 1517 commitChip(int start, int end, Editable editable)1518 private boolean commitChip(int start, int end, Editable editable) { 1519 int position = positionOfFirstEntryWithTypePerson(); 1520 if (position != -1 && enoughToFilter() 1521 && end == getSelectionEnd() && !isPhoneQuery() 1522 && !isValidEmailAddress(editable.toString().substring(start, end).trim())) { 1523 // let's choose the selected or first entry if only the input text is NOT an email 1524 // address so we won't try to replace the user's potentially correct but 1525 // new/unencountered email input 1526 final int selectedPosition = getListSelection(); 1527 if (selectedPosition == -1 || !isEntryAtPositionTypePerson(selectedPosition)) { 1528 // Nothing is selected or selected item is not type person; use the first item 1529 submitItemAtPosition(position); 1530 } else { 1531 submitItemAtPosition(selectedPosition); 1532 } 1533 dismissDropDown(); 1534 return true; 1535 } else { 1536 int tokenEnd = mTokenizer.findTokenEnd(editable, start); 1537 if (editable.length() > tokenEnd + 1) { 1538 char charAt = editable.charAt(tokenEnd + 1); 1539 if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) { 1540 tokenEnd++; 1541 } 1542 } 1543 String text = editable.toString().substring(start, tokenEnd).trim(); 1544 clearComposingText(); 1545 if (text.length() > 0 && !text.equals(" ")) { 1546 RecipientEntry entry = createTokenizedEntry(text); 1547 if (entry != null) { 1548 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1549 CharSequence chipText = createChip(entry); 1550 if (chipText != null && start > -1 && end > -1) { 1551 editable.replace(start, end, chipText); 1552 } 1553 } 1554 // Only dismiss the dropdown if it is related to the text we 1555 // just committed. 1556 // For paste, it may not be as there are possibly multiple 1557 // tokens being added. 1558 if (end == getSelectionEnd()) { 1559 dismissDropDown(); 1560 } 1561 sanitizeBetween(); 1562 return true; 1563 } 1564 } 1565 return false; 1566 } 1567 positionOfFirstEntryWithTypePerson()1568 private int positionOfFirstEntryWithTypePerson() { 1569 ListAdapter adapter = getAdapter(); 1570 int itemCount = adapter != null ? adapter.getCount() : 0; 1571 for (int i = 0; i < itemCount; i++) { 1572 if (isEntryAtPositionTypePerson(i)) { 1573 return i; 1574 } 1575 } 1576 return -1; 1577 } 1578 isEntryAtPositionTypePerson(int position)1579 private boolean isEntryAtPositionTypePerson(int position) { 1580 return getAdapter().getItem(position).getEntryType() == RecipientEntry.ENTRY_TYPE_PERSON; 1581 } 1582 1583 // Visible for testing. sanitizeBetween()1584 /* package */ void sanitizeBetween() { 1585 // Don't sanitize while we are waiting for content to chipify. 1586 if (mPendingChipsCount > 0) { 1587 return; 1588 } 1589 // Find the last chip. 1590 DrawableRecipientChip[] recips = getSortedRecipients(); 1591 if (recips != null && recips.length > 0) { 1592 DrawableRecipientChip last = recips[recips.length - 1]; 1593 DrawableRecipientChip beforeLast = null; 1594 if (recips.length > 1) { 1595 beforeLast = recips[recips.length - 2]; 1596 } 1597 int startLooking = 0; 1598 int end = getSpannable().getSpanStart(last); 1599 if (beforeLast != null) { 1600 startLooking = getSpannable().getSpanEnd(beforeLast); 1601 Editable text = getText(); 1602 if (startLooking == -1 || startLooking > text.length() - 1) { 1603 // There is nothing after this chip. 1604 return; 1605 } 1606 if (text.charAt(startLooking) == ' ') { 1607 startLooking++; 1608 } 1609 } 1610 if (startLooking >= 0 && end >= 0 && startLooking < end) { 1611 getText().delete(startLooking, end); 1612 } 1613 } 1614 } 1615 shouldCreateChip(int start, int end)1616 private boolean shouldCreateChip(int start, int end) { 1617 return !mNoChipMode && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end); 1618 } 1619 alreadyHasChip(int start, int end)1620 private boolean alreadyHasChip(int start, int end) { 1621 if (mNoChipMode) { 1622 return true; 1623 } 1624 DrawableRecipientChip[] chips = 1625 getSpannable().getSpans(start, end, DrawableRecipientChip.class); 1626 return chips != null && chips.length > 0; 1627 } 1628 handleEdit(int start, int end)1629 private void handleEdit(int start, int end) { 1630 if (start == -1 || end == -1) { 1631 // This chip no longer exists in the field. 1632 dismissDropDown(); 1633 return; 1634 } 1635 // This is in the middle of a chip, so select out the whole chip 1636 // and commit it. 1637 Editable editable = getText(); 1638 setSelection(end); 1639 String text = getText().toString().substring(start, end); 1640 if (!TextUtils.isEmpty(text)) { 1641 RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text)); 1642 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1643 CharSequence chipText = createChip(entry); 1644 int selEnd = getSelectionEnd(); 1645 if (chipText != null && start > -1 && selEnd > -1) { 1646 editable.replace(start, selEnd, chipText); 1647 } 1648 } 1649 dismissDropDown(); 1650 } 1651 1652 /** 1653 * If there is a selected chip, delegate the key events 1654 * to the selected chip. 1655 */ 1656 @Override onKeyDown(int keyCode, @NonNull KeyEvent event)1657 public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { 1658 if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) { 1659 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 1660 mAlternatesPopup.dismiss(); 1661 } 1662 removeChip(mSelectedChip); 1663 } 1664 1665 switch (keyCode) { 1666 case KeyEvent.KEYCODE_ENTER: 1667 case KeyEvent.KEYCODE_DPAD_CENTER: 1668 if (event.hasNoModifiers()) { 1669 if (commitDefault()) { 1670 return true; 1671 } 1672 if (mSelectedChip != null) { 1673 clearSelectedChip(); 1674 return true; 1675 } else if (focusNext()) { 1676 return true; 1677 } 1678 } 1679 break; 1680 } 1681 1682 return super.onKeyDown(keyCode, event); 1683 } 1684 1685 // Visible for testing. getSpannable()1686 /* package */ Spannable getSpannable() { 1687 return getText(); 1688 } 1689 getChipStart(DrawableRecipientChip chip)1690 private int getChipStart(DrawableRecipientChip chip) { 1691 return getSpannable().getSpanStart(chip); 1692 } 1693 getChipEnd(DrawableRecipientChip chip)1694 private int getChipEnd(DrawableRecipientChip chip) { 1695 return getSpannable().getSpanEnd(chip); 1696 } 1697 1698 /** 1699 * Instead of filtering on the entire contents of the edit box, 1700 * this subclass method filters on the range from 1701 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 1702 * if the length of that range meets or exceeds {@link #getThreshold} 1703 * and makes sure that the range is not already a Chip. 1704 */ 1705 @Override performFiltering(@onNull CharSequence text, int keyCode)1706 public void performFiltering(@NonNull CharSequence text, int keyCode) { 1707 boolean isCompletedToken = isCompletedToken(text); 1708 if (enoughToFilter() && !isCompletedToken) { 1709 int end = getSelectionEnd(); 1710 int start = mTokenizer.findTokenStart(text, end); 1711 // If this is a RecipientChip, don't filter 1712 // on its contents. 1713 Spannable span = getSpannable(); 1714 DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class); 1715 if (chips != null && chips.length > 0) { 1716 dismissDropDown(); 1717 return; 1718 } 1719 } else if (isCompletedToken) { 1720 dismissDropDown(); 1721 return; 1722 } 1723 super.performFiltering(text, keyCode); 1724 } 1725 1726 // Visible for testing. isCompletedToken(CharSequence text)1727 /*package*/ boolean isCompletedToken(CharSequence text) { 1728 if (TextUtils.isEmpty(text)) { 1729 return false; 1730 } 1731 // Check to see if this is a completed token before filtering. 1732 int end = text.length(); 1733 int start = mTokenizer.findTokenStart(text, end); 1734 String token = text.toString().substring(start, end).trim(); 1735 if (!TextUtils.isEmpty(token)) { 1736 char atEnd = token.charAt(token.length() - 1); 1737 return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON; 1738 } 1739 return false; 1740 } 1741 1742 /** 1743 * Clears the selected chip if there is one (and dismissing any popups related to the selected 1744 * chip in the process). 1745 */ clearSelectedChip()1746 public void clearSelectedChip() { 1747 if (mSelectedChip != null) { 1748 unselectChip(mSelectedChip); 1749 mSelectedChip = null; 1750 } 1751 setCursorVisible(true); 1752 setSelection(getText().length()); 1753 } 1754 1755 /** 1756 * Monitor touch events in the RecipientEditTextView. 1757 * If the view does not have focus, any tap on the view 1758 * will just focus the view. If the view has focus, determine 1759 * if the touch target is a recipient chip. If it is and the chip 1760 * is not selected, select it and clear any other selected chips. 1761 * If it isn't, then select that chip. 1762 */ 1763 @Override onTouchEvent(@onNull MotionEvent event)1764 public boolean onTouchEvent(@NonNull MotionEvent event) { 1765 if (!isFocused()) { 1766 // Ignore any chip taps until this view is focused. 1767 return super.onTouchEvent(event); 1768 } 1769 boolean handled = super.onTouchEvent(event); 1770 int action = event.getAction(); 1771 boolean chipWasSelected = false; 1772 if (mSelectedChip == null) { 1773 mGestureDetector.onTouchEvent(event); 1774 } 1775 if (action == MotionEvent.ACTION_UP) { 1776 float x = event.getX(); 1777 float y = event.getY(); 1778 int offset = putOffsetInRange(x, y); 1779 DrawableRecipientChip currentChip = findChip(offset); 1780 if (currentChip != null) { 1781 if (mSelectedChip != null && mSelectedChip != currentChip) { 1782 clearSelectedChip(); 1783 selectChip(currentChip); 1784 } else if (mSelectedChip == null) { 1785 commitDefault(); 1786 selectChip(currentChip); 1787 } else { 1788 onClick(mSelectedChip); 1789 } 1790 chipWasSelected = true; 1791 handled = true; 1792 } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) { 1793 chipWasSelected = true; 1794 } 1795 } 1796 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 1797 clearSelectedChip(); 1798 } 1799 return handled; 1800 } 1801 showAlternates(final DrawableRecipientChip currentChip, final ListPopupWindow alternatesPopup)1802 private void showAlternates(final DrawableRecipientChip currentChip, 1803 final ListPopupWindow alternatesPopup) { 1804 new AsyncTask<Void, Void, ListAdapter>() { 1805 @Override 1806 protected ListAdapter doInBackground(final Void... params) { 1807 return createAlternatesAdapter(currentChip); 1808 } 1809 1810 @Override 1811 protected void onPostExecute(final ListAdapter result) { 1812 if (!mAttachedToWindow) { 1813 return; 1814 } 1815 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 1816 int bottomOffset = calculateOffsetFromBottomToTop(line); 1817 1818 // Align the alternates popup with the left side of the View, 1819 // regardless of the position of the chip tapped. 1820 alternatesPopup.setAnchorView((mAlternatePopupAnchor != null) ? 1821 mAlternatePopupAnchor : RecipientEditTextView.this); 1822 alternatesPopup.setVerticalOffset(bottomOffset); 1823 alternatesPopup.setAdapter(result); 1824 alternatesPopup.setOnItemClickListener(mAlternatesListener); 1825 // Clear the checked item. 1826 mCheckedItem = -1; 1827 alternatesPopup.show(); 1828 ListView listView = alternatesPopup.getListView(); 1829 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1830 // Checked item would be -1 if the adapter has not 1831 // loaded the view that should be checked yet. The 1832 // variable will be set correctly when onCheckedItemChanged 1833 // is called in a separate thread. 1834 if (mCheckedItem != -1) { 1835 listView.setItemChecked(mCheckedItem, true); 1836 mCheckedItem = -1; 1837 } 1838 } 1839 }.execute((Void[]) null); 1840 } 1841 createAlternatesAdapter(DrawableRecipientChip chip)1842 protected ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) { 1843 return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), 1844 chip.getDirectoryId(), chip.getLookupKey(), chip.getDataId(), 1845 getAdapter().getQueryType(), this, mDropdownChipLayouter, 1846 constructStateListDeleteDrawable(), getAdapter().getPermissionsCheckListener()); 1847 } 1848 createSingleAddressAdapter(DrawableRecipientChip currentChip)1849 private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) { 1850 return new SingleRecipientArrayAdapter(getContext(), currentChip.getEntry(), 1851 mDropdownChipLayouter, constructStateListDeleteDrawable()); 1852 } 1853 constructStateListDeleteDrawable()1854 private StateListDrawable constructStateListDeleteDrawable() { 1855 // Construct the StateListDrawable from deleteDrawable 1856 StateListDrawable deleteDrawable = new StateListDrawable(); 1857 if (!mDisableDelete) { 1858 deleteDrawable.addState(new int[]{android.R.attr.state_activated}, mChipDelete); 1859 } 1860 deleteDrawable.addState(new int[0], null); 1861 return deleteDrawable; 1862 } 1863 1864 @Override onCheckedItemChanged(int position)1865 public void onCheckedItemChanged(int position) { 1866 ListView listView = mAlternatesPopup.getListView(); 1867 if (listView != null && listView.getCheckedItemCount() == 0) { 1868 listView.setItemChecked(position, true); 1869 } 1870 mCheckedItem = position; 1871 } 1872 putOffsetInRange(final float x, final float y)1873 private int putOffsetInRange(final float x, final float y) { 1874 final int offset; 1875 1876 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 1877 offset = getOffsetForPosition(x, y); 1878 } else { 1879 offset = supportGetOffsetForPosition(x, y); 1880 } 1881 1882 return putOffsetInRange(offset); 1883 } 1884 1885 // TODO: This algorithm will need a lot of tweaking after more people have used 1886 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 1887 // what comes before the finger. putOffsetInRange(int o)1888 private int putOffsetInRange(int o) { 1889 int offset = o; 1890 Editable text = getText(); 1891 int length = text.length(); 1892 // Remove whitespace from end to find "real end" 1893 int realLength = length; 1894 for (int i = length - 1; i >= 0; i--) { 1895 if (text.charAt(i) == ' ') { 1896 realLength--; 1897 } else { 1898 break; 1899 } 1900 } 1901 1902 // If the offset is beyond or at the end of the text, 1903 // leave it alone. 1904 if (offset >= realLength) { 1905 return offset; 1906 } 1907 Editable editable = getText(); 1908 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 1909 // Keep walking backward! 1910 offset--; 1911 } 1912 return offset; 1913 } 1914 findText(Editable text, int offset)1915 private static int findText(Editable text, int offset) { 1916 if (text.charAt(offset) != ' ') { 1917 return offset; 1918 } 1919 return -1; 1920 } 1921 findChip(int offset)1922 private DrawableRecipientChip findChip(int offset) { 1923 final Spannable span = getSpannable(); 1924 final DrawableRecipientChip[] chips = 1925 span.getSpans(0, span.length(), DrawableRecipientChip.class); 1926 // Find the chip that contains this offset. 1927 for (DrawableRecipientChip chip : chips) { 1928 int start = getChipStart(chip); 1929 int end = getChipEnd(chip); 1930 if (offset >= start && offset <= end) { 1931 return chip; 1932 } 1933 } 1934 return null; 1935 } 1936 1937 // Visible for testing. 1938 // Use this method to generate text to add to the list of addresses. createAddressText(RecipientEntry entry)1939 /* package */String createAddressText(RecipientEntry entry) { 1940 String display = entry.getDisplayName(); 1941 String address = entry.getDestination(); 1942 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1943 display = null; 1944 } 1945 String trimmedDisplayText; 1946 if (isPhoneQuery() && PhoneUtil.isPhoneNumber(address)) { 1947 trimmedDisplayText = address.trim(); 1948 } else { 1949 if (address != null) { 1950 // Tokenize out the address in case the address already 1951 // contained the username as well. 1952 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address); 1953 if (tokenized != null && tokenized.length > 0) { 1954 address = tokenized[0].getAddress(); 1955 } 1956 } 1957 Rfc822Token token = new Rfc822Token(display, address, null); 1958 trimmedDisplayText = token.toString().trim(); 1959 } 1960 int index = trimmedDisplayText.indexOf(","); 1961 return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText) 1962 && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer 1963 .terminateToken(trimmedDisplayText) : trimmedDisplayText; 1964 } 1965 1966 // Visible for testing. 1967 // Use this method to generate text to display in a chip. createChipDisplayText(RecipientEntry entry)1968 /*package*/ String createChipDisplayText(RecipientEntry entry) { 1969 String display = entry.getDisplayName(); 1970 String address = entry.getDestination(); 1971 if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) { 1972 display = null; 1973 } 1974 if (!TextUtils.isEmpty(display)) { 1975 return display; 1976 } else if (!TextUtils.isEmpty(address)){ 1977 return address; 1978 } else { 1979 return new Rfc822Token(display, address, null).toString(); 1980 } 1981 } 1982 createChip(RecipientEntry entry)1983 private CharSequence createChip(RecipientEntry entry) { 1984 final String displayText = createAddressText(entry); 1985 if (TextUtils.isEmpty(displayText)) { 1986 return null; 1987 } 1988 // Always leave a blank space at the end of a chip. 1989 final int textLength = displayText.length() - 1; 1990 final SpannableString chipText = new SpannableString(displayText); 1991 if (!mNoChipMode) { 1992 try { 1993 DrawableRecipientChip chip = constructChipSpan(entry); 1994 chipText.setSpan(chip, 0, textLength, 1995 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1996 chip.setOriginalText(chipText.toString()); 1997 } catch (NullPointerException e) { 1998 Log.e(TAG, e.getMessage(), e); 1999 return null; 2000 } 2001 } 2002 onChipCreated(entry); 2003 return chipText; 2004 } 2005 2006 /** 2007 * A callback for subclasses to use to know when a chip was created with the 2008 * given RecipientEntry. 2009 */ onChipCreated(RecipientEntry entry)2010 protected void onChipCreated(RecipientEntry entry) { 2011 if (!mNoChipMode && mRecipientChipAddedListener != null) { 2012 mRecipientChipAddedListener.onRecipientChipAdded(entry); 2013 } 2014 } 2015 2016 /** 2017 * When an item in the suggestions list has been clicked, create a chip from the 2018 * contact information of the selected item. 2019 */ 2020 @Override onItemClick(AdapterView<?> parent, View view, int position, long id)2021 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 2022 if (position < 0) { 2023 return; 2024 } 2025 2026 final RecipientEntry entry = getAdapter().getItem(position); 2027 if (entry.getEntryType() == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) { 2028 if (mPermissionsRequestItemClickedListener != null) { 2029 mPermissionsRequestItemClickedListener 2030 .onPermissionsRequestItemClicked(this, entry.getPermissions()); 2031 } 2032 return; 2033 } 2034 2035 final int charactersTyped = submitItemAtPosition(position); 2036 if (charactersTyped > -1 && mRecipientEntryItemClickedListener != null) { 2037 mRecipientEntryItemClickedListener 2038 .onRecipientEntryItemClicked(charactersTyped, position); 2039 } 2040 } 2041 submitItemAtPosition(int position)2042 private int submitItemAtPosition(int position) { 2043 RecipientEntry entry = createValidatedEntry(getAdapter().getItem(position)); 2044 if (entry == null) { 2045 return -1; 2046 } 2047 clearComposingText(); 2048 2049 int end = getSelectionEnd(); 2050 int start = mTokenizer.findTokenStart(getText(), end); 2051 2052 Editable editable = getText(); 2053 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 2054 CharSequence chip = createChip(entry); 2055 if (chip != null && start >= 0 && end >= 0) { 2056 editable.replace(start, end, chip); 2057 } 2058 sanitizeBetween(); 2059 2060 return end - start; 2061 } 2062 createValidatedEntry(RecipientEntry item)2063 private RecipientEntry createValidatedEntry(RecipientEntry item) { 2064 if (item == null) { 2065 return null; 2066 } 2067 final RecipientEntry entry; 2068 // If the display name and the address are the same, or if this is a 2069 // valid contact, but the destination is invalid, then make this a fake 2070 // recipient that is editable. 2071 String destination = item.getDestination(); 2072 if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) { 2073 entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(), 2074 destination, item.isValid()); 2075 } else if (RecipientEntry.isCreatedRecipient(item.getContactId()) 2076 && (TextUtils.isEmpty(item.getDisplayName()) 2077 || TextUtils.equals(item.getDisplayName(), destination) 2078 || (mValidator != null && !mValidator.isValid(destination)))) { 2079 entry = RecipientEntry.constructFakeEntry(destination, item.isValid()); 2080 } else { 2081 entry = item; 2082 } 2083 return entry; 2084 } 2085 2086 // Visible for testing. getSortedRecipients()2087 /* package */DrawableRecipientChip[] getSortedRecipients() { 2088 DrawableRecipientChip[] recips = getSpannable() 2089 .getSpans(0, getText().length(), DrawableRecipientChip.class); 2090 ArrayList<DrawableRecipientChip> recipientsList = new ArrayList<DrawableRecipientChip>( 2091 Arrays.asList(recips)); 2092 final Spannable spannable = getSpannable(); 2093 Collections.sort(recipientsList, new Comparator<DrawableRecipientChip>() { 2094 2095 @Override 2096 public int compare(DrawableRecipientChip first, DrawableRecipientChip second) { 2097 int firstStart = spannable.getSpanStart(first); 2098 int secondStart = spannable.getSpanStart(second); 2099 if (firstStart < secondStart) { 2100 return -1; 2101 } else if (firstStart > secondStart) { 2102 return 1; 2103 } else { 2104 return 0; 2105 } 2106 } 2107 }); 2108 return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]); 2109 } 2110 2111 @Override onActionItemClicked(ActionMode mode, MenuItem item)2112 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 2113 return false; 2114 } 2115 2116 @Override onDestroyActionMode(ActionMode mode)2117 public void onDestroyActionMode(ActionMode mode) { 2118 } 2119 2120 @Override onPrepareActionMode(ActionMode mode, Menu menu)2121 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 2122 return false; 2123 } 2124 2125 /** 2126 * No chips are selectable. 2127 */ 2128 @Override onCreateActionMode(ActionMode mode, Menu menu)2129 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 2130 return false; 2131 } 2132 2133 // Visible for testing. getMoreChip()2134 /* package */ReplacementDrawableSpan getMoreChip() { 2135 MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(), 2136 MoreImageSpan.class); 2137 return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null; 2138 } 2139 createMoreSpan(int count)2140 private MoreImageSpan createMoreSpan(int count) { 2141 String moreText = String.format(mMoreItem.getText().toString(), count); 2142 mWorkPaint.set(getPaint()); 2143 mWorkPaint.setTextSize(mMoreItem.getTextSize()); 2144 mWorkPaint.setColor(mMoreItem.getCurrentTextColor()); 2145 final int width = (int) mWorkPaint.measureText(moreText) + mMoreItem.getPaddingLeft() 2146 + mMoreItem.getPaddingRight(); 2147 final int height = (int) mChipHeight; 2148 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 2149 Canvas canvas = new Canvas(drawable); 2150 int adjustedHeight = height; 2151 Layout layout = getLayout(); 2152 if (layout != null) { 2153 adjustedHeight -= layout.getLineDescent(0); 2154 } 2155 canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, mWorkPaint); 2156 2157 Drawable result = new BitmapDrawable(getResources(), drawable); 2158 result.setBounds(0, 0, width, height); 2159 return new MoreImageSpan(result); 2160 } 2161 2162 // Visible for testing. createMoreChipPlainText()2163 /*package*/ void createMoreChipPlainText() { 2164 // Take the first <= CHIP_LIMIT addresses and get to the end of the second one. 2165 Editable text = getText(); 2166 int start = 0; 2167 int end = start; 2168 for (int i = 0; i < CHIP_LIMIT; i++) { 2169 end = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 2170 start = end; // move to the next token and get its end. 2171 } 2172 // Now, count total addresses. 2173 int tokenCount = countTokens(text); 2174 MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT); 2175 SpannableString chipText = new SpannableString(text.subSequence(end, text.length())); 2176 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2177 text.replace(end, text.length(), chipText); 2178 mMoreChip = moreSpan; 2179 } 2180 2181 // Visible for testing. countTokens(Editable text)2182 /* package */int countTokens(Editable text) { 2183 int tokenCount = 0; 2184 int start = 0; 2185 while (start < text.length()) { 2186 start = movePastTerminators(mTokenizer.findTokenEnd(text, start)); 2187 tokenCount++; 2188 if (start >= text.length()) { 2189 break; 2190 } 2191 } 2192 return tokenCount; 2193 } 2194 2195 /** 2196 * Create the more chip. The more chip is text that replaces any chips that 2197 * do not fit in the pre-defined available space when the 2198 * RecipientEditTextView loses focus. 2199 */ 2200 // Visible for testing. createMoreChip()2201 /* package */ void createMoreChip() { 2202 if (mNoChipMode) { 2203 createMoreChipPlainText(); 2204 return; 2205 } 2206 2207 if (!mShouldShrink) { 2208 return; 2209 } 2210 ReplacementDrawableSpan[] tempMore = getSpannable().getSpans(0, getText().length(), 2211 MoreImageSpan.class); 2212 if (tempMore.length > 0) { 2213 getSpannable().removeSpan(tempMore[0]); 2214 } 2215 DrawableRecipientChip[] recipients = getSortedRecipients(); 2216 2217 if (recipients == null || recipients.length <= CHIP_LIMIT) { 2218 mMoreChip = null; 2219 return; 2220 } 2221 Spannable spannable = getSpannable(); 2222 int numRecipients = recipients.length; 2223 int overage = numRecipients - CHIP_LIMIT; 2224 MoreImageSpan moreSpan = createMoreSpan(overage); 2225 mHiddenSpans = new ArrayList<DrawableRecipientChip>(); 2226 int totalReplaceStart = 0; 2227 int totalReplaceEnd = 0; 2228 Editable text = getText(); 2229 for (int i = numRecipients - overage; i < recipients.length; i++) { 2230 mHiddenSpans.add(recipients[i]); 2231 if (i == numRecipients - overage) { 2232 totalReplaceStart = spannable.getSpanStart(recipients[i]); 2233 } 2234 if (i == recipients.length - 1) { 2235 totalReplaceEnd = spannable.getSpanEnd(recipients[i]); 2236 } 2237 if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) { 2238 int spanStart = spannable.getSpanStart(recipients[i]); 2239 int spanEnd = spannable.getSpanEnd(recipients[i]); 2240 recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd)); 2241 } 2242 spannable.removeSpan(recipients[i]); 2243 } 2244 if (totalReplaceEnd < text.length()) { 2245 totalReplaceEnd = text.length(); 2246 } 2247 int end = Math.max(totalReplaceStart, totalReplaceEnd); 2248 int start = Math.min(totalReplaceStart, totalReplaceEnd); 2249 SpannableString chipText = new SpannableString(text.subSequence(start, end)); 2250 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2251 text.replace(start, end, chipText); 2252 mMoreChip = moreSpan; 2253 // If adding the +more chip goes over the limit, resize accordingly. 2254 if (!isPhoneQuery() && getLineCount() > mMaxLines) { 2255 setMaxLines(getLineCount()); 2256 } 2257 } 2258 2259 /** 2260 * Replace the more chip, if it exists, with all of the recipient chips it had 2261 * replaced when the RecipientEditTextView gains focus. 2262 */ 2263 // Visible for testing. removeMoreChip()2264 /*package*/ void removeMoreChip() { 2265 if (mMoreChip != null) { 2266 Spannable span = getSpannable(); 2267 span.removeSpan(mMoreChip); 2268 mMoreChip = null; 2269 // Re-add the spans that were hidden. 2270 if (mHiddenSpans != null && mHiddenSpans.size() > 0) { 2271 // Recreate each hidden span. 2272 DrawableRecipientChip[] recipients = getSortedRecipients(); 2273 // Start the search for tokens after the last currently visible 2274 // chip. 2275 if (recipients == null || recipients.length == 0) { 2276 return; 2277 } 2278 int end = span.getSpanEnd(recipients[recipients.length - 1]); 2279 Editable editable = getText(); 2280 for (DrawableRecipientChip chip : mHiddenSpans) { 2281 int chipStart; 2282 int chipEnd; 2283 String token; 2284 // Need to find the location of the chip, again. 2285 token = (String) chip.getOriginalText(); 2286 // As we find the matching recipient for the hidden spans, 2287 // reduce the size of the string we need to search. 2288 // That way, if there are duplicates, we always find the correct 2289 // recipient. 2290 chipStart = editable.toString().indexOf(token, end); 2291 end = chipEnd = Math.min(editable.length(), chipStart + token.length()); 2292 // Only set the span if we found a matching token. 2293 if (chipStart != -1) { 2294 editable.setSpan(chip, chipStart, chipEnd, 2295 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 2296 } 2297 } 2298 mHiddenSpans.clear(); 2299 } 2300 } 2301 } 2302 2303 /** 2304 * Show specified chip as selected. If the RecipientChip is just an email address, 2305 * selecting the chip will take the contents of the chip and place it at 2306 * the end of the RecipientEditTextView for inline editing. If the 2307 * RecipientChip is a complete contact, then selecting the chip 2308 * will show a popup window with the address in use highlighted and any other 2309 * alternate addresses for the contact. 2310 * @param currentChip Chip to select. 2311 */ selectChip(DrawableRecipientChip currentChip)2312 private void selectChip(DrawableRecipientChip currentChip) { 2313 if (shouldShowEditableText(currentChip)) { 2314 CharSequence text = currentChip.getValue(); 2315 Editable editable = getText(); 2316 Spannable spannable = getSpannable(); 2317 int spanStart = spannable.getSpanStart(currentChip); 2318 int spanEnd = spannable.getSpanEnd(currentChip); 2319 spannable.removeSpan(currentChip); 2320 // Don't need leading space if it's the only chip 2321 if (spanEnd - spanStart == editable.length() - 1) { 2322 spanEnd++; 2323 } 2324 editable.delete(spanStart, spanEnd); 2325 setCursorVisible(true); 2326 setSelection(editable.length()); 2327 editable.append(text); 2328 mSelectedChip = constructChipSpan( 2329 RecipientEntry.constructFakeEntry((String) text, isValid(text.toString()))); 2330 2331 /* 2332 * Because chip is destroyed and converted into an editable text, we call 2333 * {@link RecipientChipDeletedListener#onRecipientChipDeleted}. For the cases where 2334 * editable text is not shown (i.e. chip is in user's contact list), chip is focused 2335 * and below callback is not called. 2336 */ 2337 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2338 mRecipientChipDeletedListener.onRecipientChipDeleted(currentChip.getEntry()); 2339 } 2340 } else { 2341 final boolean showAddress = 2342 currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT || 2343 getAdapter().forceShowAddress(); 2344 if (showAddress && mNoChipMode) { 2345 return; 2346 } 2347 2348 if (isTouchExplorationEnabled()) { 2349 // The chips cannot be touch-explored. However, doing a double-tap results in 2350 // the popup being shown for the last chip, which is of no value. 2351 return; 2352 } 2353 2354 mSelectedChip = currentChip; 2355 setSelection(getText().getSpanEnd(mSelectedChip)); 2356 setCursorVisible(false); 2357 2358 if (showAddress) { 2359 showAddress(currentChip, mAddressPopup); 2360 } else { 2361 showAlternates(currentChip, mAlternatesPopup); 2362 } 2363 } 2364 } 2365 isTouchExplorationEnabled()2366 private boolean isTouchExplorationEnabled() { 2367 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { 2368 return false; 2369 } 2370 2371 final AccessibilityManager accessibilityManager = (AccessibilityManager) 2372 getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); 2373 return accessibilityManager.isTouchExplorationEnabled(); 2374 } 2375 shouldShowEditableText(DrawableRecipientChip currentChip)2376 private boolean shouldShowEditableText(DrawableRecipientChip currentChip) { 2377 long contactId = currentChip.getContactId(); 2378 return contactId == RecipientEntry.INVALID_CONTACT 2379 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 2380 } 2381 showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup)2382 private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup) { 2383 if (!mAttachedToWindow) { 2384 return; 2385 } 2386 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 2387 int bottomOffset = calculateOffsetFromBottomToTop(line); 2388 // Align the alternates popup with the left side of the View, 2389 // regardless of the position of the chip tapped. 2390 popup.setAnchorView((mAlternatePopupAnchor != null) ? mAlternatePopupAnchor : this); 2391 popup.setVerticalOffset(bottomOffset); 2392 popup.setAdapter(createSingleAddressAdapter(currentChip)); 2393 popup.setOnItemClickListener(new OnItemClickListener() { 2394 @Override 2395 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 2396 unselectChip(currentChip); 2397 popup.dismiss(); 2398 } 2399 }); 2400 popup.show(); 2401 ListView listView = popup.getListView(); 2402 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 2403 listView.setItemChecked(0, true); 2404 } 2405 2406 /** 2407 * Remove selection from this chip. Unselecting a RecipientChip will render 2408 * the chip without a delete icon and with an unfocused background. This is 2409 * called when the RecipientChip no longer has focus. 2410 */ unselectChip(DrawableRecipientChip chip)2411 private void unselectChip(DrawableRecipientChip chip) { 2412 int start = getChipStart(chip); 2413 int end = getChipEnd(chip); 2414 Editable editable = getText(); 2415 mSelectedChip = null; 2416 if (start == -1 || end == -1) { 2417 Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing"); 2418 setSelection(editable.length()); 2419 commitDefault(); 2420 } else { 2421 getSpannable().removeSpan(chip); 2422 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 2423 editable.removeSpan(chip); 2424 try { 2425 if (!mNoChipMode) { 2426 editable.setSpan(constructChipSpan(chip.getEntry()), 2427 start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2428 } 2429 } catch (NullPointerException e) { 2430 Log.e(TAG, e.getMessage(), e); 2431 } 2432 } 2433 setCursorVisible(true); 2434 setSelection(editable.length()); 2435 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 2436 mAlternatesPopup.dismiss(); 2437 } 2438 } 2439 2440 @Override onChipDelete()2441 public void onChipDelete() { 2442 if (mSelectedChip != null) { 2443 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2444 mRecipientChipDeletedListener.onRecipientChipDeleted(mSelectedChip.getEntry()); 2445 } 2446 removeChip(mSelectedChip); 2447 } 2448 dismissPopups(); 2449 } 2450 2451 @Override onPermissionRequestDismissed()2452 public void onPermissionRequestDismissed() { 2453 if (mPermissionsRequestItemClickedListener != null) { 2454 mPermissionsRequestItemClickedListener.onPermissionRequestDismissed(); 2455 } 2456 dismissDropDown(); 2457 } 2458 dismissPopups()2459 private void dismissPopups() { 2460 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 2461 mAlternatesPopup.dismiss(); 2462 } 2463 if (mAddressPopup != null && mAddressPopup.isShowing()) { 2464 mAddressPopup.dismiss(); 2465 } 2466 setSelection(getText().length()); 2467 } 2468 2469 /** 2470 * Remove the chip and any text associated with it from the RecipientEditTextView. 2471 */ 2472 // Visible for testing. removeChip(DrawableRecipientChip chip)2473 /* package */void removeChip(DrawableRecipientChip chip) { 2474 Spannable spannable = getSpannable(); 2475 int spanStart = spannable.getSpanStart(chip); 2476 int spanEnd = spannable.getSpanEnd(chip); 2477 Editable text = getText(); 2478 int toDelete = spanEnd; 2479 boolean wasSelected = chip == mSelectedChip; 2480 // Clear that there is a selected chip before updating any text. 2481 if (wasSelected) { 2482 mSelectedChip = null; 2483 } 2484 // Always remove trailing spaces when removing a chip. 2485 while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') { 2486 toDelete++; 2487 } 2488 spannable.removeSpan(chip); 2489 if (spanStart >= 0 && toDelete > 0) { 2490 text.delete(spanStart, toDelete); 2491 } 2492 if (wasSelected) { 2493 clearSelectedChip(); 2494 } 2495 } 2496 2497 /** 2498 * Replace this currently selected chip with a new chip 2499 * that uses the contact data provided. 2500 */ 2501 // Visible for testing. replaceChip(DrawableRecipientChip chip, RecipientEntry entry)2502 /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) { 2503 boolean wasSelected = chip == mSelectedChip; 2504 if (wasSelected) { 2505 mSelectedChip = null; 2506 } 2507 int start = getChipStart(chip); 2508 int end = getChipEnd(chip); 2509 getSpannable().removeSpan(chip); 2510 Editable editable = getText(); 2511 CharSequence chipText = createChip(entry); 2512 if (chipText != null) { 2513 if (start == -1 || end == -1) { 2514 Log.e(TAG, "The chip to replace does not exist but should."); 2515 editable.insert(0, chipText); 2516 } else { 2517 if (!TextUtils.isEmpty(chipText)) { 2518 // There may be a space to replace with this chip's new 2519 // associated space. Check for it 2520 int toReplace = end; 2521 while (toReplace >= 0 && toReplace < editable.length() 2522 && editable.charAt(toReplace) == ' ') { 2523 toReplace++; 2524 } 2525 editable.replace(start, toReplace, chipText); 2526 } 2527 } 2528 } 2529 setCursorVisible(true); 2530 if (wasSelected) { 2531 clearSelectedChip(); 2532 } 2533 } 2534 2535 /** 2536 * Handle click events for a chip. When a selected chip receives a click 2537 * event, see if that event was in the delete icon. If so, delete it. 2538 * Otherwise, unselect the chip. 2539 */ onClick(DrawableRecipientChip chip)2540 public void onClick(DrawableRecipientChip chip) { 2541 if (chip.isSelected()) { 2542 clearSelectedChip(); 2543 } 2544 } 2545 chipsPending()2546 private boolean chipsPending() { 2547 return mPendingChipsCount > 0 || (mHiddenSpans != null && mHiddenSpans.size() > 0); 2548 } 2549 2550 @Override removeTextChangedListener(TextWatcher watcher)2551 public void removeTextChangedListener(TextWatcher watcher) { 2552 mTextWatcher = null; 2553 super.removeTextChangedListener(watcher); 2554 } 2555 isValidEmailAddress(String input)2556 private boolean isValidEmailAddress(String input) { 2557 return !TextUtils.isEmpty(input) && mValidator != null && 2558 mValidator.isValid(input); 2559 } 2560 2561 private class RecipientTextWatcher implements TextWatcher { 2562 2563 @Override afterTextChanged(Editable s)2564 public void afterTextChanged(Editable s) { 2565 // If the text has been set to null or empty, make sure we remove 2566 // all the spans we applied. 2567 if (TextUtils.isEmpty(s)) { 2568 // Remove all the chips spans. 2569 Spannable spannable = getSpannable(); 2570 DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(), 2571 DrawableRecipientChip.class); 2572 for (DrawableRecipientChip chip : chips) { 2573 spannable.removeSpan(chip); 2574 } 2575 if (mMoreChip != null) { 2576 spannable.removeSpan(mMoreChip); 2577 } 2578 clearSelectedChip(); 2579 return; 2580 } 2581 // Get whether there are any recipients pending addition to the 2582 // view. If there are, don't do anything in the text watcher. 2583 if (chipsPending()) { 2584 return; 2585 } 2586 // If the user is editing a chip, don't clear it. 2587 if (mSelectedChip != null) { 2588 if (!isGeneratedContact(mSelectedChip)) { 2589 setCursorVisible(true); 2590 setSelection(getText().length()); 2591 clearSelectedChip(); 2592 } else { 2593 return; 2594 } 2595 } 2596 int length = s.length(); 2597 // Make sure there is content there to parse and that it is 2598 // not just the commit character. 2599 if (length > 1) { 2600 if (lastCharacterIsCommitCharacter(s)) { 2601 commitByCharacter(); 2602 return; 2603 } 2604 char last; 2605 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2606 int len = length() - 1; 2607 if (end != len) { 2608 last = s.charAt(end); 2609 } else { 2610 last = s.charAt(len); 2611 } 2612 if (last == COMMIT_CHAR_SPACE) { 2613 if (!isPhoneQuery()) { 2614 // Check if this is a valid email address. If it is, 2615 // commit it. 2616 String text = getText().toString(); 2617 int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2618 String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text, 2619 tokenStart)); 2620 if (isValidEmailAddress(sub)) { 2621 commitByCharacter(); 2622 } 2623 } 2624 } 2625 } 2626 } 2627 2628 @Override onTextChanged(CharSequence s, int start, int before, int count)2629 public void onTextChanged(CharSequence s, int start, int before, int count) { 2630 // The user deleted some text OR some text was replaced; check to 2631 // see if the insertion point is on a space 2632 // following a chip. 2633 if (before - count == 1) { 2634 // If the item deleted is a space, and the thing before the 2635 // space is a chip, delete the entire span. 2636 int selStart = getSelectionStart(); 2637 DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart, 2638 DrawableRecipientChip.class); 2639 if (repl.length > 0) { 2640 // There is a chip there! Just remove it. 2641 DrawableRecipientChip toDelete = repl[0]; 2642 Editable editable = getText(); 2643 // Add the separator token. 2644 int deleteStart = editable.getSpanStart(toDelete); 2645 int deleteEnd = editable.getSpanEnd(toDelete) + 1; 2646 if (deleteEnd > editable.length()) { 2647 deleteEnd = editable.length(); 2648 } 2649 if (!mNoChipMode && mRecipientChipDeletedListener != null) { 2650 mRecipientChipDeletedListener.onRecipientChipDeleted(toDelete.getEntry()); 2651 } 2652 editable.removeSpan(toDelete); 2653 editable.delete(deleteStart, deleteEnd); 2654 } 2655 } else if (count > before) { 2656 if (mSelectedChip != null 2657 && isGeneratedContact(mSelectedChip)) { 2658 if (lastCharacterIsCommitCharacter(s)) { 2659 commitByCharacter(); 2660 return; 2661 } 2662 } 2663 } 2664 } 2665 2666 @Override beforeTextChanged(CharSequence s, int start, int count, int after)2667 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 2668 // Do nothing. 2669 } 2670 } 2671 lastCharacterIsCommitCharacter(CharSequence s)2672 public boolean lastCharacterIsCommitCharacter(CharSequence s) { 2673 char last; 2674 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 2675 int len = length() - 1; 2676 if (end != len) { 2677 last = s.charAt(end); 2678 } else { 2679 last = s.charAt(len); 2680 } 2681 return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON; 2682 } 2683 isGeneratedContact(DrawableRecipientChip chip)2684 public boolean isGeneratedContact(DrawableRecipientChip chip) { 2685 long contactId = chip.getContactId(); 2686 return contactId == RecipientEntry.INVALID_CONTACT 2687 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT); 2688 } 2689 2690 /** 2691 * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}. 2692 */ 2693 // Visible for testing. handlePasteClip(ClipData clip)2694 void handlePasteClip(ClipData clip) { 2695 if (clip == null) { 2696 // Do nothing. 2697 return; 2698 } 2699 2700 final ClipDescription clipDesc = clip.getDescription(); 2701 boolean containsSupportedType = clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || 2702 clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML); 2703 if (!containsSupportedType) { 2704 return; 2705 } 2706 2707 removeTextChangedListener(mTextWatcher); 2708 2709 final ClipDescription clipDescription = clip.getDescription(); 2710 for (int i = 0; i < clip.getItemCount(); i++) { 2711 final String mimeType = clipDescription.getMimeType(i); 2712 final boolean supportedType = ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType) || 2713 ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType); 2714 if (!supportedType) { 2715 // Only plain text and html can be pasted. 2716 continue; 2717 } 2718 2719 final CharSequence pastedItem = clip.getItemAt(i).getText(); 2720 if (!TextUtils.isEmpty(pastedItem)) { 2721 final Editable editable = getText(); 2722 final int start = getSelectionStart(); 2723 final int end = getSelectionEnd(); 2724 if (start < 0 || end < 1) { 2725 // No selection. 2726 editable.append(pastedItem); 2727 } else if (start == end) { 2728 // Insert at position. 2729 editable.insert(start, pastedItem); 2730 } else { 2731 editable.append(pastedItem, start, end); 2732 } 2733 handlePasteAndReplace(); 2734 } 2735 } 2736 2737 mHandler.post(mAddTextWatcher); 2738 } 2739 2740 @Override onTextContextMenuItem(int id)2741 public boolean onTextContextMenuItem(int id) { 2742 if (id == android.R.id.paste) { 2743 ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService( 2744 Context.CLIPBOARD_SERVICE); 2745 handlePasteClip(clipboard.getPrimaryClip()); 2746 return true; 2747 } 2748 return super.onTextContextMenuItem(id); 2749 } 2750 handlePasteAndReplace()2751 private void handlePasteAndReplace() { 2752 ArrayList<DrawableRecipientChip> created = handlePaste(); 2753 if (created != null && created.size() > 0) { 2754 // Perform reverse lookups on the pasted contacts. 2755 IndividualReplacementTask replace = new IndividualReplacementTask(); 2756 replace.execute(created); 2757 } 2758 } 2759 2760 // Visible for testing. handlePaste()2761 /* package */ArrayList<DrawableRecipientChip> handlePaste() { 2762 String text = getText().toString(); 2763 int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 2764 String lastAddress = text.substring(originalTokenStart); 2765 int tokenStart = originalTokenStart; 2766 int prevTokenStart = 0; 2767 DrawableRecipientChip findChip = null; 2768 ArrayList<DrawableRecipientChip> created = new ArrayList<DrawableRecipientChip>(); 2769 if (tokenStart != 0) { 2770 // There are things before this! 2771 while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) { 2772 prevTokenStart = tokenStart; 2773 tokenStart = mTokenizer.findTokenStart(text, tokenStart); 2774 findChip = findChip(tokenStart); 2775 if (tokenStart == originalTokenStart && findChip == null) { 2776 break; 2777 } 2778 } 2779 if (tokenStart != originalTokenStart) { 2780 if (findChip != null) { 2781 tokenStart = prevTokenStart; 2782 } 2783 int tokenEnd; 2784 DrawableRecipientChip createdChip; 2785 while (tokenStart < originalTokenStart) { 2786 tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(), 2787 tokenStart)); 2788 commitChip(tokenStart, tokenEnd, getText()); 2789 createdChip = findChip(tokenStart); 2790 if (createdChip == null) { 2791 break; 2792 } 2793 // +1 for the space at the end. 2794 tokenStart = getSpannable().getSpanEnd(createdChip) + 1; 2795 created.add(createdChip); 2796 } 2797 } 2798 } 2799 // Take a look at the last token. If the token has been completed with a 2800 // commit character, create a chip. 2801 if (isCompletedToken(lastAddress)) { 2802 Editable editable = getText(); 2803 tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart); 2804 commitChip(tokenStart, editable.length(), editable); 2805 created.add(findChip(tokenStart)); 2806 } 2807 return created; 2808 } 2809 2810 // Visible for testing. movePastTerminators(int tokenEnd)2811 /* package */int movePastTerminators(int tokenEnd) { 2812 if (tokenEnd >= length()) { 2813 return tokenEnd; 2814 } 2815 char atEnd = getText().toString().charAt(tokenEnd); 2816 if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) { 2817 tokenEnd++; 2818 } 2819 // This token had not only an end token character, but also a space 2820 // separating it from the next token. 2821 if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') { 2822 tokenEnd++; 2823 } 2824 return tokenEnd; 2825 } 2826 2827 private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> { createFreeChip(RecipientEntry entry)2828 private DrawableRecipientChip createFreeChip(RecipientEntry entry) { 2829 try { 2830 if (mNoChipMode) { 2831 return null; 2832 } 2833 return constructChipSpan(entry); 2834 } catch (NullPointerException e) { 2835 Log.e(TAG, e.getMessage(), e); 2836 return null; 2837 } 2838 } 2839 2840 @Override onPreExecute()2841 protected void onPreExecute() { 2842 // Ensure everything is in chip-form already, so we don't have text that slowly gets 2843 // replaced 2844 final List<DrawableRecipientChip> originalRecipients = 2845 new ArrayList<DrawableRecipientChip>(); 2846 final DrawableRecipientChip[] existingChips = getSortedRecipients(); 2847 Collections.addAll(originalRecipients, existingChips); 2848 if (mHiddenSpans != null) { 2849 originalRecipients.addAll(mHiddenSpans); 2850 } 2851 2852 final List<DrawableRecipientChip> replacements = 2853 new ArrayList<DrawableRecipientChip>(originalRecipients.size()); 2854 2855 for (final DrawableRecipientChip chip : originalRecipients) { 2856 if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId()) 2857 && getSpannable().getSpanStart(chip) != -1) { 2858 replacements.add(createFreeChip(chip.getEntry())); 2859 } else { 2860 replacements.add(null); 2861 } 2862 } 2863 2864 processReplacements(originalRecipients, replacements); 2865 } 2866 2867 @Override doInBackground(Void... params)2868 protected Void doInBackground(Void... params) { 2869 if (mIndividualReplacements != null) { 2870 mIndividualReplacements.cancel(true); 2871 } 2872 // For each chip in the list, look up the matching contact. 2873 // If there is a match, replace that chip with the matching 2874 // chip. 2875 final ArrayList<DrawableRecipientChip> recipients = 2876 new ArrayList<DrawableRecipientChip>(); 2877 DrawableRecipientChip[] existingChips = getSortedRecipients(); 2878 Collections.addAll(recipients, existingChips); 2879 if (mHiddenSpans != null) { 2880 recipients.addAll(mHiddenSpans); 2881 } 2882 ArrayList<String> addresses = new ArrayList<String>(); 2883 for (DrawableRecipientChip chip : recipients) { 2884 if (chip != null) { 2885 addresses.add(createAddressText(chip.getEntry())); 2886 } 2887 } 2888 final BaseRecipientAdapter adapter = getAdapter(); 2889 adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { 2890 @Override 2891 public void matchesFound(Map<String, RecipientEntry> entries) { 2892 final ArrayList<DrawableRecipientChip> replacements = 2893 new ArrayList<DrawableRecipientChip>(); 2894 for (final DrawableRecipientChip temp : recipients) { 2895 RecipientEntry entry = null; 2896 if (temp != null && RecipientEntry.isCreatedRecipient( 2897 temp.getEntry().getContactId()) 2898 && getSpannable().getSpanStart(temp) != -1) { 2899 // Replace this. 2900 entry = createValidatedEntry( 2901 entries.get(tokenizeAddress(temp.getEntry() 2902 .getDestination()))); 2903 } 2904 if (entry != null) { 2905 replacements.add(createFreeChip(entry)); 2906 } else { 2907 replacements.add(null); 2908 } 2909 } 2910 processReplacements(recipients, replacements); 2911 } 2912 2913 @Override 2914 public void matchesNotFound(final Set<String> unfoundAddresses) { 2915 final List<DrawableRecipientChip> replacements = 2916 new ArrayList<DrawableRecipientChip>(unfoundAddresses.size()); 2917 2918 for (final DrawableRecipientChip temp : recipients) { 2919 if (temp != null && RecipientEntry.isCreatedRecipient( 2920 temp.getEntry().getContactId()) 2921 && getSpannable().getSpanStart(temp) != -1) { 2922 if (unfoundAddresses.contains( 2923 temp.getEntry().getDestination())) { 2924 replacements.add(createFreeChip(temp.getEntry())); 2925 } else { 2926 replacements.add(null); 2927 } 2928 } else { 2929 replacements.add(null); 2930 } 2931 } 2932 2933 processReplacements(recipients, replacements); 2934 } 2935 }); 2936 return null; 2937 } 2938 processReplacements(final List<DrawableRecipientChip> recipients, final List<DrawableRecipientChip> replacements)2939 private void processReplacements(final List<DrawableRecipientChip> recipients, 2940 final List<DrawableRecipientChip> replacements) { 2941 if (replacements != null && replacements.size() > 0) { 2942 final Runnable runnable = new Runnable() { 2943 @Override 2944 public void run() { 2945 final Editable text = new SpannableStringBuilder(getText()); 2946 int i = 0; 2947 for (final DrawableRecipientChip chip : recipients) { 2948 final DrawableRecipientChip replacement = replacements.get(i); 2949 if (replacement != null) { 2950 final RecipientEntry oldEntry = chip.getEntry(); 2951 final RecipientEntry newEntry = replacement.getEntry(); 2952 final boolean isBetter = 2953 RecipientAlternatesAdapter.getBetterRecipient( 2954 oldEntry, newEntry) == newEntry; 2955 2956 if (isBetter) { 2957 // Find the location of the chip in the text currently shown. 2958 final int start = text.getSpanStart(chip); 2959 if (start != -1) { 2960 // Replacing the entirety of what the chip represented, 2961 // including the extra space dividing it from other chips. 2962 final int end = 2963 Math.min(text.getSpanEnd(chip) + 1, text.length()); 2964 text.removeSpan(chip); 2965 // Make sure we always have just 1 space at the end to 2966 // separate this chip from the next chip. 2967 final SpannableString displayText = 2968 new SpannableString(createAddressText( 2969 replacement.getEntry()).trim() + " "); 2970 displayText.setSpan(replacement, 0, 2971 displayText.length() - 1, 2972 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 2973 // Replace the old text we found with with the new display 2974 // text, which now may also contain the display name of the 2975 // recipient. 2976 text.replace(start, end, displayText); 2977 replacement.setOriginalText(displayText.toString()); 2978 replacements.set(i, null); 2979 2980 recipients.set(i, replacement); 2981 } 2982 } 2983 } 2984 i++; 2985 } 2986 setText(text); 2987 } 2988 }; 2989 2990 if (Looper.myLooper() == Looper.getMainLooper()) { 2991 runnable.run(); 2992 } else { 2993 mHandler.post(runnable); 2994 } 2995 } 2996 } 2997 } 2998 2999 private class IndividualReplacementTask 3000 extends AsyncTask<ArrayList<DrawableRecipientChip>, Void, Void> { 3001 @Override doInBackground(ArrayList<DrawableRecipientChip>.... params)3002 protected Void doInBackground(ArrayList<DrawableRecipientChip>... params) { 3003 // For each chip in the list, look up the matching contact. 3004 // If there is a match, replace that chip with the matching 3005 // chip. 3006 final ArrayList<DrawableRecipientChip> originalRecipients = params[0]; 3007 ArrayList<String> addresses = new ArrayList<String>(); 3008 for (DrawableRecipientChip chip : originalRecipients) { 3009 if (chip != null) { 3010 addresses.add(createAddressText(chip.getEntry())); 3011 } 3012 } 3013 final BaseRecipientAdapter adapter = getAdapter(); 3014 adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() { 3015 3016 @Override 3017 public void matchesFound(Map<String, RecipientEntry> entries) { 3018 for (final DrawableRecipientChip temp : originalRecipients) { 3019 if (RecipientEntry.isCreatedRecipient(temp.getEntry() 3020 .getContactId()) 3021 && getSpannable().getSpanStart(temp) != -1) { 3022 // Replace this. 3023 final RecipientEntry entry = createValidatedEntry(entries 3024 .get(tokenizeAddress(temp.getEntry().getDestination()) 3025 .toLowerCase())); 3026 if (entry != null) { 3027 mHandler.post(new Runnable() { 3028 @Override 3029 public void run() { 3030 replaceChip(temp, entry); 3031 } 3032 }); 3033 } 3034 } 3035 } 3036 } 3037 3038 @Override 3039 public void matchesNotFound(final Set<String> unfoundAddresses) { 3040 // No action required 3041 } 3042 }); 3043 return null; 3044 } 3045 } 3046 3047 3048 /** 3049 * MoreImageSpan is a simple class created for tracking the existence of a 3050 * more chip across activity restarts/ 3051 */ 3052 private class MoreImageSpan extends ReplacementDrawableSpan { MoreImageSpan(Drawable b)3053 public MoreImageSpan(Drawable b) { 3054 super(b); 3055 setExtraMargin(mLineSpacingExtra); 3056 } 3057 } 3058 3059 @Override onDown(MotionEvent e)3060 public boolean onDown(MotionEvent e) { 3061 return false; 3062 } 3063 3064 @Override onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)3065 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { 3066 // Do nothing. 3067 return false; 3068 } 3069 3070 @Override onLongPress(MotionEvent event)3071 public void onLongPress(MotionEvent event) { 3072 if (mSelectedChip != null) { 3073 return; 3074 } 3075 float x = event.getX(); 3076 float y = event.getY(); 3077 final int offset = putOffsetInRange(x, y); 3078 DrawableRecipientChip currentChip = findChip(offset); 3079 if (currentChip != null) { 3080 if (mDragEnabled) { 3081 // Start drag-and-drop for the selected chip. 3082 startDrag(currentChip); 3083 } else { 3084 // Copy the selected chip email address. 3085 showCopyDialog(currentChip.getEntry().getDestination()); 3086 } 3087 } 3088 } 3089 3090 // The following methods are used to provide some functionality on older versions of Android 3091 // These methods were copied out of JB MR2's TextView 3092 ///////////////////////////////////////////////// supportGetOffsetForPosition(float x, float y)3093 private int supportGetOffsetForPosition(float x, float y) { 3094 if (getLayout() == null) return -1; 3095 final int line = supportGetLineAtCoordinate(y); 3096 return supportGetOffsetAtCoordinate(line, x); 3097 } 3098 supportConvertToLocalHorizontalCoordinate(float x)3099 private float supportConvertToLocalHorizontalCoordinate(float x) { 3100 x -= getTotalPaddingLeft(); 3101 // Clamp the position to inside of the view. 3102 x = Math.max(0.0f, x); 3103 x = Math.min(getWidth() - getTotalPaddingRight() - 1, x); 3104 x += getScrollX(); 3105 return x; 3106 } 3107 supportGetLineAtCoordinate(float y)3108 private int supportGetLineAtCoordinate(float y) { 3109 y -= getTotalPaddingLeft(); 3110 // Clamp the position to inside of the view. 3111 y = Math.max(0.0f, y); 3112 y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y); 3113 y += getScrollY(); 3114 return getLayout().getLineForVertical((int) y); 3115 } 3116 supportGetOffsetAtCoordinate(int line, float x)3117 private int supportGetOffsetAtCoordinate(int line, float x) { 3118 x = supportConvertToLocalHorizontalCoordinate(x); 3119 return getLayout().getOffsetForHorizontal(line, x); 3120 } 3121 ///////////////////////////////////////////////// 3122 3123 /** 3124 * Enables drag-and-drop for chips. 3125 */ enableDrag()3126 public void enableDrag() { 3127 mDragEnabled = true; 3128 } 3129 3130 /** 3131 * Starts drag-and-drop for the selected chip. 3132 */ startDrag(DrawableRecipientChip currentChip)3133 private void startDrag(DrawableRecipientChip currentChip) { 3134 String address = currentChip.getEntry().getDestination(); 3135 ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA); 3136 3137 // Start drag mode. 3138 startDrag(data, new RecipientChipShadow(currentChip), null, 0); 3139 3140 // Remove the current chip, so drag-and-drop will result in a move. 3141 // TODO (phamm): consider readd this chip if it's dropped outside a target. 3142 removeChip(currentChip); 3143 } 3144 3145 /** 3146 * Handles drag event. 3147 */ 3148 @Override onDragEvent(@onNull DragEvent event)3149 public boolean onDragEvent(@NonNull DragEvent event) { 3150 switch (event.getAction()) { 3151 case DragEvent.ACTION_DRAG_STARTED: 3152 // Only handle plain text drag and drop. 3153 return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); 3154 case DragEvent.ACTION_DRAG_ENTERED: 3155 requestFocus(); 3156 return true; 3157 case DragEvent.ACTION_DROP: 3158 handlePasteClip(event.getClipData()); 3159 return true; 3160 } 3161 return false; 3162 } 3163 3164 /** 3165 * Drag shadow for a {@link DrawableRecipientChip}. 3166 */ 3167 private final class RecipientChipShadow extends DragShadowBuilder { 3168 private final DrawableRecipientChip mChip; 3169 RecipientChipShadow(DrawableRecipientChip chip)3170 public RecipientChipShadow(DrawableRecipientChip chip) { 3171 mChip = chip; 3172 } 3173 3174 @Override onProvideShadowMetrics(@onNull Point shadowSize, @NonNull Point shadowTouchPoint)3175 public void onProvideShadowMetrics(@NonNull Point shadowSize, 3176 @NonNull Point shadowTouchPoint) { 3177 Rect rect = mChip.getBounds(); 3178 shadowSize.set(rect.width(), rect.height()); 3179 shadowTouchPoint.set(rect.centerX(), rect.centerY()); 3180 } 3181 3182 @Override onDrawShadow(@onNull Canvas canvas)3183 public void onDrawShadow(@NonNull Canvas canvas) { 3184 mChip.draw(canvas); 3185 } 3186 } 3187 showCopyDialog(final String address)3188 private void showCopyDialog(final String address) { 3189 final Context context = getContext(); 3190 if (!mAttachedToWindow || context == null || !(context instanceof Activity)) { 3191 return; 3192 } 3193 3194 final DialogFragment fragment = CopyDialog.newInstance(address); 3195 fragment.show(((Activity) context).getFragmentManager(), CopyDialog.TAG); 3196 } 3197 3198 @Override onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)3199 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { 3200 // Do nothing. 3201 return false; 3202 } 3203 3204 @Override onShowPress(MotionEvent e)3205 public void onShowPress(MotionEvent e) { 3206 // Do nothing. 3207 } 3208 3209 @Override onSingleTapUp(MotionEvent e)3210 public boolean onSingleTapUp(MotionEvent e) { 3211 // Do nothing. 3212 return false; 3213 } 3214 isPhoneQuery()3215 protected boolean isPhoneQuery() { 3216 return getAdapter() != null 3217 && getAdapter().getQueryType() == BaseRecipientAdapter.QUERY_TYPE_PHONE; 3218 } 3219 3220 @Override getAdapter()3221 public BaseRecipientAdapter getAdapter() { 3222 return (BaseRecipientAdapter) super.getAdapter(); 3223 } 3224 3225 /** 3226 * Append a new {@link RecipientEntry} to the end of the recipient chips, leaving any 3227 * unfinished text at the end. 3228 */ appendRecipientEntry(final RecipientEntry entry)3229 public void appendRecipientEntry(final RecipientEntry entry) { 3230 clearComposingText(); 3231 3232 final Editable editable = getText(); 3233 int chipInsertionPoint = 0; 3234 3235 // Find the end of last chip and see if there's any unchipified text. 3236 final DrawableRecipientChip[] recips = getSortedRecipients(); 3237 if (recips != null && recips.length > 0) { 3238 final DrawableRecipientChip last = recips[recips.length - 1]; 3239 // The chip will be inserted at the end of last chip + 1. All the unfinished text after 3240 // the insertion point will be kept untouched. 3241 chipInsertionPoint = editable.getSpanEnd(last) + 1; 3242 } 3243 3244 final CharSequence chip = createChip(entry); 3245 if (chip != null) { 3246 editable.insert(chipInsertionPoint, chip); 3247 } 3248 } 3249 3250 /** 3251 * Remove all chips matching the given RecipientEntry. 3252 */ removeRecipientEntry(final RecipientEntry entry)3253 public void removeRecipientEntry(final RecipientEntry entry) { 3254 final DrawableRecipientChip[] recips = getText() 3255 .getSpans(0, getText().length(), DrawableRecipientChip.class); 3256 3257 for (final DrawableRecipientChip recipient : recips) { 3258 final RecipientEntry existingEntry = recipient.getEntry(); 3259 if (existingEntry != null && existingEntry.isValid() && 3260 existingEntry.isSamePerson(entry)) { 3261 removeChip(recipient); 3262 } 3263 } 3264 } 3265 setAlternatePopupAnchor(View v)3266 public void setAlternatePopupAnchor(View v) { 3267 mAlternatePopupAnchor = v; 3268 } 3269 3270 @Override setVisibility(int visibility)3271 public void setVisibility(int visibility) { 3272 super.setVisibility(visibility); 3273 3274 if (visibility != GONE && mRequiresShrinkWhenNotGone) { 3275 mRequiresShrinkWhenNotGone = false; 3276 mHandler.post(mDelayedShrink); 3277 } 3278 } 3279 3280 private static class ChipBitmapContainer { 3281 Bitmap bitmap; 3282 // information used for positioning the loaded icon 3283 boolean loadIcon = true; 3284 float left; 3285 float top; 3286 float right; 3287 float bottom; 3288 } 3289 } 3290