Home | History | Annotate | Download | only in ui
      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 {
     61         public CharSequence fixText(CharSequence invalidText) {
     62             return invalidText;
     63         }
     64 
     65         public boolean isValid(CharSequence text) {
     66             return true;
     67         }
     68     }
     69 
     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
    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 
    137     public void setOnSelectChipRunnable(Runnable onSelectChipRunnable) {
    138         mOnSelectChipRunnable = onSelectChipRunnable;
    139     }
    140 
    141     @Override
    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 
    158     public int getRecipientCount() {
    159         return mTokenizer.getNumbers().size();
    160     }
    161 
    162     public List<String> getNumbers() {
    163         return mTokenizer.getNumbers();
    164     }
    165 
    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 
    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 
    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 
    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 
    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 
    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 
    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 
    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 
    282     private int pointToPosition(int x, int y) {
    283         // Check layout before getExtendedPaddingTop().
    284         // mLayout is used in getExtendedPaddingTop().
    285         Layout layout = getLayout();
    286         if (layout == null) {
    287             return -1;
    288         }
    289 
    290         x -= getCompoundPaddingLeft();
    291         y -= getExtendedPaddingTop();
    292 
    293 
    294         x += getScrollX();
    295         y += getScrollY();
    296 
    297         int line = layout.getLineForVertical(y);
    298         int off = layout.getOffsetForHorizontal(line, x);
    299 
    300         return off;
    301     }
    302 
    303     @Override
    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
    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 
    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 
    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 
    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 
    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
    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
    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
    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 
    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 
    494         RecipientContextMenuInfo(Contact r) {
    495             recipient = r;
    496         }
    497     }
    498 }
    499