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.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 {
     64         public CharSequence fixText(CharSequence invalidText) {
     65             return invalidText;
     66         }
     67 
     68         public boolean isValid(CharSequence text) {
     69             return true;
     70         }
     71     }
     72 
     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
    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 
    147     public void setOnSelectChipRunnable(Runnable onSelectChipRunnable) {
    148         mOnSelectChipRunnable = onSelectChipRunnable;
    149     }
    150 
    151     @Override
    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 
    168     public int getRecipientCount() {
    169         return mTokenizer.getNumbers().size();
    170     }
    171 
    172     public List<String> getNumbers() {
    173         return mTokenizer.getNumbers();
    174     }
    175 
    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 
    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 
    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 
    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 
    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 
    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 
    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 
    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 
    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
    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
    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 
    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 
    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 
    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 
    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
    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
    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
    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 
    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 
    504         RecipientContextMenuInfo(Contact r) {
    505             recipient = r;
    506         }
    507     }
    508 }
    509