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