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