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