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