1 /* 2 * Copyright (C) 2008 Esmertec AG. 3 * Copyright (C) 2008 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.mms.ui; 19 20 import java.util.ArrayList; 21 import java.util.List; 22 23 import android.content.Context; 24 import android.provider.Telephony.Mms; 25 import android.telephony.PhoneNumberUtils; 26 import android.text.Annotation; 27 import android.text.Editable; 28 import android.text.Layout; 29 import android.text.Spannable; 30 import android.text.SpannableString; 31 import android.text.Spanned; 32 import android.text.TextUtils; 33 import android.text.TextWatcher; 34 import android.text.util.Rfc822Token; 35 import android.text.util.Rfc822Tokenizer; 36 import android.util.AttributeSet; 37 import android.view.ContextMenu.ContextMenuInfo; 38 import android.view.LayoutInflater; 39 import android.view.MotionEvent; 40 import android.view.View; 41 import android.view.inputmethod.EditorInfo; 42 import android.widget.AdapterView; 43 import android.widget.MultiAutoCompleteTextView; 44 45 import com.android.ex.chips.DropdownChipLayouter; 46 import com.android.ex.chips.RecipientEditTextView; 47 import com.android.mms.MmsConfig; 48 import com.android.mms.R; 49 import com.android.mms.data.Contact; 50 import com.android.mms.data.ContactList; 51 52 /** 53 * Provide UI for editing the recipients of multi-media messages. 54 */ 55 public class RecipientsEditor extends RecipientEditTextView { 56 private int mLongPressedPosition = -1; 57 private final RecipientsEditorTokenizer mTokenizer; 58 private char mLastSeparator = ','; 59 private Runnable mOnSelectChipRunnable; 60 private final AddressValidator mInternalValidator; 61 62 /** A noop validator that does not munge invalid texts and claims any address is valid */ 63 private class AddressValidator implements Validator { 64 public CharSequence fixText(CharSequence invalidText) { 65 return invalidText; 66 } 67 68 public boolean isValid(CharSequence text) { 69 return true; 70 } 71 } 72 73 public RecipientsEditor(Context context, AttributeSet attrs) { 74 super(context, attrs); 75 76 mTokenizer = new RecipientsEditorTokenizer(); 77 setTokenizer(mTokenizer); 78 79 mInternalValidator = new AddressValidator(); 80 super.setValidator(mInternalValidator); 81 82 // For the focus to move to the message body when soft Next is pressed 83 setImeOptions(EditorInfo.IME_ACTION_NEXT); 84 85 setThreshold(1); // pop-up the list after a single char is typed 86 87 /* 88 * The point of this TextWatcher is that when the user chooses 89 * an address completion from the AutoCompleteTextView menu, it 90 * is marked up with Annotation objects to tie it back to the 91 * address book entry that it came from. If the user then goes 92 * back and edits that part of the text, it no longer corresponds 93 * to that address book entry and needs to have the Annotations 94 * claiming that it does removed. 95 */ 96 addTextChangedListener(new TextWatcher() { 97 private Annotation[] mAffected; 98 99 @Override 100 public void beforeTextChanged(CharSequence s, int start, 101 int count, int after) { 102 mAffected = ((Spanned) s).getSpans(start, start + count, 103 Annotation.class); 104 } 105 106 @Override 107 public void onTextChanged(CharSequence s, int start, 108 int before, int after) { 109 if (before == 0 && after == 1) { // inserting a character 110 char c = s.charAt(start); 111 if (c == ',' || c == ';') { 112 // Remember the delimiter the user typed to end this recipient. We'll 113 // need it shortly in terminateToken(). 114 mLastSeparator = c; 115 } 116 } 117 } 118 119 @Override 120 public void afterTextChanged(Editable s) { 121 if (mAffected != null) { 122 for (Annotation a : mAffected) { 123 s.removeSpan(a); 124 } 125 } 126 mAffected = null; 127 } 128 }); 129 130 setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context) { 131 @Override 132 protected int getItemLayoutResId(AdapterType type) { 133 return R.layout.mms_chips_recipient_dropdown_item; 134 } 135 }); 136 } 137 138 @Override 139 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 140 super.onItemClick(parent, view, position, id); 141 142 if (mOnSelectChipRunnable != null) { 143 mOnSelectChipRunnable.run(); 144 } 145 } 146 147 public void setOnSelectChipRunnable(Runnable onSelectChipRunnable) { 148 mOnSelectChipRunnable = onSelectChipRunnable; 149 } 150 151 @Override 152 public boolean enoughToFilter() { 153 if (!super.enoughToFilter()) { 154 return false; 155 } 156 // If the user is in the middle of editing an existing recipient, don't offer the 157 // auto-complete menu. Without this, when the user selects an auto-complete menu item, 158 // it will get added to the list of recipients so we end up with the old before-editing 159 // recipient and the new post-editing recipient. As a precedent, gmail does not show 160 // the auto-complete menu when editing an existing recipient. 161 int end = getSelectionEnd(); 162 int len = getText().length(); 163 164 return end == len; 165 166 } 167 168 public int getRecipientCount() { 169 return mTokenizer.getNumbers().size(); 170 } 171 172 public List<String> getNumbers() { 173 return mTokenizer.getNumbers(); 174 } 175 176 public ContactList constructContactsFromInput(boolean blocking) { 177 List<String> numbers = mTokenizer.getNumbers(); 178 ContactList list = new ContactList(); 179 for (String number : numbers) { 180 Contact contact = Contact.get(number, blocking); 181 contact.setNumber(number); 182 list.add(contact); 183 } 184 return list; 185 } 186 187 private boolean isValidAddress(String number, boolean isMms) { 188 if (isMms) { 189 return MessageUtils.isValidMmsAddress(number); 190 } else { 191 // TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid 192 // GSM SMS address. If the address contains a dialable char, it considers it a well 193 // formed SMS addr. CDMA doesn't work that way and has a different parser for SMS 194 // address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!! 195 return PhoneNumberUtils.isWellFormedSmsAddress(number) 196 || Mms.isEmailAddress(number); 197 } 198 } 199 200 public boolean hasValidRecipient(boolean isMms) { 201 for (String number : mTokenizer.getNumbers()) { 202 if (isValidAddress(number, isMms)) 203 return true; 204 } 205 return false; 206 } 207 208 public boolean hasInvalidRecipient(boolean isMms) { 209 for (String number : mTokenizer.getNumbers()) { 210 if (!isValidAddress(number, isMms)) { 211 if (MmsConfig.getEmailGateway() == null) { 212 return true; 213 } else if (!MessageUtils.isAlias(number)) { 214 return true; 215 } 216 } 217 } 218 return false; 219 } 220 221 public String formatInvalidNumbers(boolean isMms) { 222 StringBuilder sb = new StringBuilder(); 223 for (String number : mTokenizer.getNumbers()) { 224 if (!isValidAddress(number, isMms)) { 225 if (sb.length() != 0) { 226 sb.append(", "); 227 } 228 sb.append(number); 229 } 230 } 231 return sb.toString(); 232 } 233 234 public boolean containsEmail() { 235 if (TextUtils.indexOf(getText(), '@') == -1) 236 return false; 237 238 List<String> numbers = mTokenizer.getNumbers(); 239 for (String number : numbers) { 240 if (Mms.isEmailAddress(number)) 241 return true; 242 } 243 return false; 244 } 245 246 public static CharSequence contactToToken(Contact c) { 247 SpannableString s = new SpannableString(c.getNameAndNumber()); 248 int len = s.length(); 249 250 if (len == 0) { 251 return s; 252 } 253 254 s.setSpan(new Annotation("number", c.getNumber()), 0, len, 255 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 256 257 return s; 258 } 259 260 public void populate(ContactList list) { 261 // Very tricky bug. In the recipient editor, we always leave a trailing 262 // comma to make it easy for users to add additional recipients. When a 263 // user types (or chooses from the dropdown) a new contact Mms has never 264 // seen before, the contact gets the correct trailing comma. But when the 265 // contact gets added to the mms's contacts table, contacts sends out an 266 // onUpdate to CMA. CMA would recompute the recipients and since the 267 // recipient editor was still visible, call mRecipientsEditor.populate(recipients). 268 // This would replace the recipient that had a comma with a recipient 269 // without a comma. When a user manually added a new comma to add another 270 // recipient, this would eliminate the span inside the text. The span contains the 271 // number part of "Fred Flinstone <123-1231>". Hence, the whole 272 // "Fred Flinstone <123-1231>" would be considered the number of 273 // the first recipient and get entered into the canonical_addresses table. 274 // The fix for this particular problem is very easy. All recipients have commas. 275 // TODO: However, the root problem remains. If a user enters the recipients editor 276 // and deletes chars into an address chosen from the suggestions, it'll cause 277 // the number annotation to get deleted and the whole address (name + number) will 278 // be used as the number. 279 if (list.size() == 0) { 280 // The base class RecipientEditTextView will ignore empty text. That's why we need 281 // this special case. 282 setText(null); 283 } else { 284 for (Contact c : list) { 285 // Calling setText to set the recipients won't create chips, 286 // but calling append() will. 287 append(contactToToken(c) + ","); 288 } 289 } 290 } 291 292 private int pointToPosition(int x, int y) { 293 // Check layout before getExtendedPaddingTop(). 294 // mLayout is used in getExtendedPaddingTop(). 295 Layout layout = getLayout(); 296 if (layout == null) { 297 return -1; 298 } 299 300 x -= getCompoundPaddingLeft(); 301 y -= getExtendedPaddingTop(); 302 303 304 x += getScrollX(); 305 y += getScrollY(); 306 307 int line = layout.getLineForVertical(y); 308 int off = layout.getOffsetForHorizontal(line, x); 309 310 return off; 311 } 312 313 @Override 314 public boolean onTouchEvent(MotionEvent ev) { 315 final int action = ev.getAction(); 316 final int x = (int) ev.getX(); 317 final int y = (int) ev.getY(); 318 319 if (action == MotionEvent.ACTION_DOWN) { 320 mLongPressedPosition = pointToPosition(x, y); 321 } 322 323 return super.onTouchEvent(ev); 324 } 325 326 @Override 327 protected ContextMenuInfo getContextMenuInfo() { 328 if ((mLongPressedPosition >= 0)) { 329 Spanned text = getText(); 330 if (mLongPressedPosition <= text.length()) { 331 int start = mTokenizer.findTokenStart(text, mLongPressedPosition); 332 int end = mTokenizer.findTokenEnd(text, start); 333 334 if (end != start) { 335 String number = getNumberAt(getText(), start, end, getContext()); 336 Contact c = Contact.get(number, false); 337 return new RecipientContextMenuInfo(c); 338 } 339 } 340 } 341 return null; 342 } 343 344 private static String getNumberAt(Spanned sp, int start, int end, Context context) { 345 String number = getFieldAt("number", sp, start, end, context); 346 number = PhoneNumberUtils.replaceUnicodeDigits(number); 347 if (!TextUtils.isEmpty(number)) { 348 int pos = number.indexOf('<'); 349 if (pos >= 0 && pos < number.indexOf('>')) { 350 // The number looks like an Rfc882 address, i.e. <fred flinstone> 891-7823 351 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(number); 352 if (tokens.length == 0) { 353 return number; 354 } 355 return tokens[0].getAddress(); 356 } 357 } 358 return number; 359 } 360 361 private static int getSpanLength(Spanned sp, int start, int end, Context context) { 362 // TODO: there's a situation where the span can lose its annotations: 363 // - add an auto-complete contact 364 // - add another auto-complete contact 365 // - delete that second contact and keep deleting into the first 366 // - we lose the annotation and can no longer get the span. 367 // Need to fix this case because it breaks auto-complete contacts with commas in the name. 368 Annotation[] a = sp.getSpans(start, end, Annotation.class); 369 if (a.length > 0) { 370 return sp.getSpanEnd(a[0]); 371 } 372 return 0; 373 } 374 375 private static String getFieldAt(String field, Spanned sp, int start, int end, 376 Context context) { 377 Annotation[] a = sp.getSpans(start, end, Annotation.class); 378 String fieldValue = getAnnotation(a, field); 379 if (TextUtils.isEmpty(fieldValue)) { 380 fieldValue = TextUtils.substring(sp, start, end); 381 } 382 return fieldValue; 383 384 } 385 386 private static String getAnnotation(Annotation[] a, String key) { 387 for (int i = 0; i < a.length; i++) { 388 if (a[i].getKey().equals(key)) { 389 return a[i].getValue(); 390 } 391 } 392 393 return ""; 394 } 395 396 private class RecipientsEditorTokenizer 397 implements MultiAutoCompleteTextView.Tokenizer { 398 399 @Override 400 public int findTokenStart(CharSequence text, int cursor) { 401 int i = cursor; 402 char c; 403 404 // If we're sitting at a delimiter, back up so we find the previous token 405 if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) { 406 --i; 407 } 408 // Now back up until the start or until we find the separator of the previous token 409 while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') { 410 i--; 411 } 412 while (i < cursor && text.charAt(i) == ' ') { 413 i++; 414 } 415 416 return i; 417 } 418 419 @Override 420 public int findTokenEnd(CharSequence text, int cursor) { 421 int i = cursor; 422 int len = text.length(); 423 char c; 424 425 while (i < len) { 426 if ((c = text.charAt(i)) == ',' || c == ';') { 427 return i; 428 } else { 429 i++; 430 } 431 } 432 433 return len; 434 } 435 436 @Override 437 public CharSequence terminateToken(CharSequence text) { 438 int i = text.length(); 439 440 while (i > 0 && text.charAt(i - 1) == ' ') { 441 i--; 442 } 443 444 char c; 445 if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) { 446 return text; 447 } else { 448 // Use the same delimiter the user just typed. 449 // This lets them have a mixture of commas and semicolons in their list. 450 String separator = mLastSeparator + " "; 451 if (text instanceof Spanned) { 452 SpannableString sp = new SpannableString(text + separator); 453 TextUtils.copySpansFrom((Spanned) text, 0, text.length(), 454 Object.class, sp, 0); 455 return sp; 456 } else { 457 return text + separator; 458 } 459 } 460 } 461 462 public List<String> getNumbers() { 463 Spanned sp = RecipientsEditor.this.getText(); 464 int len = sp.length(); 465 List<String> list = new ArrayList<String>(); 466 467 int start = 0; 468 int i = 0; 469 while (i < len + 1) { 470 char c; 471 if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) { 472 if (i > start) { 473 list.add(getNumberAt(sp, start, i, getContext())); 474 475 // calculate the recipients total length. This is so if the name contains 476 // commas or semis, we'll skip over the whole name to the next 477 // recipient, rather than parsing this single name into multiple 478 // recipients. 479 int spanLen = getSpanLength(sp, start, i, getContext()); 480 if (spanLen > i) { 481 i = spanLen; 482 } 483 } 484 485 i++; 486 487 while ((i < len) && (sp.charAt(i) == ' ')) { 488 i++; 489 } 490 491 start = i; 492 } else { 493 i++; 494 } 495 } 496 497 return list; 498 } 499 } 500 501 static class RecipientContextMenuInfo implements ContextMenuInfo { 502 final Contact recipient; 503 504 RecipientContextMenuInfo(Contact r) { 505 recipient = r; 506 } 507 } 508 } 509