• 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 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