• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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