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