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