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