RecipientsEditor.java revision 715e32f97bd9d8ce4b5ba650b97ba4b137150456
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
18package com.android.mms.ui;
19
20import com.android.mms.MmsConfig;
21import com.android.mms.data.Contact;
22import com.android.mms.data.ContactList;
23
24import android.content.Context;
25import android.provider.Telephony.Mms;
26import android.telephony.PhoneNumberUtils;
27import android.text.Annotation;
28import android.text.Editable;
29import android.text.Layout;
30import android.text.Spannable;
31import android.text.SpannableString;
32import android.text.SpannableStringBuilder;
33import android.text.Spanned;
34import android.text.TextUtils;
35import android.text.TextWatcher;
36import android.util.AttributeSet;
37import android.view.inputmethod.EditorInfo;
38import android.view.MotionEvent;
39import android.view.ContextMenu.ContextMenuInfo;
40import android.widget.MultiAutoCompleteTextView;
41
42import java.util.ArrayList;
43import java.util.List;
44
45/**
46 * Provide UI for editing the recipients of multi-media messages.
47 */
48public 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
97                mAffected = null;
98            }
99        });
100    }
101
102    @Override
103    public boolean enoughToFilter() {
104        if (!super.enoughToFilter()) {
105            return false;
106        }
107        // If the user is in the middle of editing an existing recipient, don't offer the
108        // auto-complete menu. Without this, when the user selects an auto-complete menu item,
109        // it will get added to the list of recipients so we end up with the old before-editing
110        // recipient and the new post-editing recipient. As a precedent, gmail does not show
111        // the auto-complete menu when editing an existing recipient.
112        int end = getSelectionEnd();
113        int len = getText().length();
114
115        return end == len;
116
117    }
118
119    public int getRecipientCount() {
120        return mTokenizer.getNumbers().size();
121    }
122
123    public List<String> getNumbers() {
124        return mTokenizer.getNumbers();
125    }
126
127    public ContactList constructContactsFromInput() {
128        List<String> numbers = mTokenizer.getNumbers();
129        ContactList list = new ContactList();
130        for (String number : numbers) {
131            Contact contact = Contact.get(number, false);
132            contact.setNumber(number);
133            list.add(contact);
134        }
135        return list;
136    }
137
138    private boolean isValidAddress(String number, boolean isMms) {
139        if (isMms) {
140            return MessageUtils.isValidMmsAddress(number);
141        } else {
142            // TODO: PhoneNumberUtils.isWellFormedSmsAddress() only check if the number is a valid
143            // GSM SMS address. If the address contains a dialable char, it considers it a well
144            // formed SMS addr. CDMA doesn't work that way and has a different parser for SMS
145            // address (see CdmaSmsAddress.parse(String address)). We should definitely fix this!!!
146            return PhoneNumberUtils.isWellFormedSmsAddress(number)
147                    || Mms.isEmailAddress(number);
148        }
149    }
150
151    public boolean hasValidRecipient(boolean isMms) {
152        for (String number : mTokenizer.getNumbers()) {
153            if (isValidAddress(number, isMms))
154                return true;
155        }
156        return false;
157    }
158
159    public boolean hasInvalidRecipient(boolean isMms) {
160        for (String number : mTokenizer.getNumbers()) {
161            if (!isValidAddress(number, isMms)) {
162                if (MmsConfig.getEmailGateway() == null) {
163                    return true;
164                } else if (!MessageUtils.isAlias(number)) {
165                    return true;
166                }
167            }
168        }
169        return false;
170    }
171
172    public String formatInvalidNumbers(boolean isMms) {
173        StringBuilder sb = new StringBuilder();
174        for (String number : mTokenizer.getNumbers()) {
175            if (!isValidAddress(number, isMms)) {
176                if (sb.length() != 0) {
177                    sb.append(", ");
178                }
179                sb.append(number);
180            }
181        }
182        return sb.toString();
183    }
184
185    public boolean containsEmail() {
186        if (TextUtils.indexOf(getText(), '@') == -1)
187            return false;
188
189        List<String> numbers = mTokenizer.getNumbers();
190        for (String number : numbers) {
191            if (Mms.isEmailAddress(number))
192                return true;
193        }
194        return false;
195    }
196
197    public static CharSequence contactToToken(Contact c) {
198        SpannableString s = new SpannableString(c.getNameAndNumber());
199        int len = s.length();
200
201        if (len == 0) {
202            return s;
203        }
204
205        s.setSpan(new Annotation("number", c.getNumber()), 0, len,
206                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
207
208        return s;
209    }
210
211    public void populate(ContactList list) {
212        SpannableStringBuilder sb = new SpannableStringBuilder();
213
214        for (Contact c : list) {
215            if (sb.length() != 0) {
216                sb.append(", ");
217            }
218
219            sb.append(contactToToken(c));
220        }
221
222        setText(sb);
223    }
224
225    private int pointToPosition(int x, int y) {
226        x -= getCompoundPaddingLeft();
227        y -= getExtendedPaddingTop();
228
229
230        x += getScrollX();
231        y += getScrollY();
232
233        Layout layout = getLayout();
234        if (layout == null) {
235            return -1;
236        }
237
238        int line = layout.getLineForVertical(y);
239        int off = layout.getOffsetForHorizontal(line, x);
240
241        return off;
242    }
243
244    @Override
245    public boolean onTouchEvent(MotionEvent ev) {
246        final int action = ev.getAction();
247        final int x = (int) ev.getX();
248        final int y = (int) ev.getY();
249
250        if (action == MotionEvent.ACTION_DOWN) {
251            mLongPressedPosition = pointToPosition(x, y);
252        }
253
254        return super.onTouchEvent(ev);
255    }
256
257    @Override
258    protected ContextMenuInfo getContextMenuInfo() {
259        if ((mLongPressedPosition >= 0)) {
260            Spanned text = getText();
261            if (mLongPressedPosition <= text.length()) {
262                int start = mTokenizer.findTokenStart(text, mLongPressedPosition);
263                int end = mTokenizer.findTokenEnd(text, start);
264
265                if (end != start) {
266                    String number = getNumberAt(getText(), start, end, mContext);
267                    Contact c = Contact.get(number, false);
268                    return new RecipientContextMenuInfo(c);
269                }
270            }
271        }
272        return null;
273    }
274
275    private static String getNumberAt(Spanned sp, int start, int end, Context context) {
276        return getFieldAt("number", sp, start, end, context);
277    }
278
279    private static int getSpanLength(Spanned sp, int start, int end, Context context) {
280        // TODO: there's a situation where the span can lose its annotations:
281        //   - add an auto-complete contact
282        //   - add another auto-complete contact
283        //   - delete that second contact and keep deleting into the first
284        //   - we lose the annotation and can no longer get the span.
285        // Need to fix this case because it breaks auto-complete contacts with commas in the name.
286        Annotation[] a = sp.getSpans(start, end, Annotation.class);
287        if (a.length > 0) {
288            return sp.getSpanEnd(a[0]);
289        }
290        return 0;
291    }
292
293    private static String getFieldAt(String field, Spanned sp, int start, int end,
294            Context context) {
295        Annotation[] a = sp.getSpans(start, end, Annotation.class);
296        String fieldValue = getAnnotation(a, field);
297        if (TextUtils.isEmpty(fieldValue)) {
298            fieldValue = TextUtils.substring(sp, start, end);
299        }
300        return fieldValue;
301
302    }
303
304    private static String getAnnotation(Annotation[] a, String key) {
305        for (int i = 0; i < a.length; i++) {
306            if (a[i].getKey().equals(key)) {
307                return a[i].getValue();
308            }
309        }
310
311        return "";
312    }
313
314    private class RecipientsEditorTokenizer
315            implements MultiAutoCompleteTextView.Tokenizer {
316        private final MultiAutoCompleteTextView mList;
317        private final Context mContext;
318
319        RecipientsEditorTokenizer(Context context, MultiAutoCompleteTextView list) {
320            mList = list;
321            mContext = context;
322        }
323
324        /**
325         * Returns the start of the token that ends at offset
326         * <code>cursor</code> within <code>text</code>.
327         * It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
328         */
329        public int findTokenStart(CharSequence text, int cursor) {
330            int i = cursor;
331            char c;
332
333            while (i > 0 && (c = text.charAt(i - 1)) != ',' && c != ';') {
334                i--;
335            }
336            while (i < cursor && text.charAt(i) == ' ') {
337                i++;
338            }
339
340            return i;
341        }
342
343        /**
344         * Returns the end of the token (minus trailing punctuation)
345         * that begins at offset <code>cursor</code> within <code>text</code>.
346         * It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
347         */
348        public int findTokenEnd(CharSequence text, int cursor) {
349            int i = cursor;
350            int len = text.length();
351            char c;
352
353            while (i < len) {
354                if ((c = text.charAt(i)) == ',' || c == ';') {
355                    return i;
356                } else {
357                    i++;
358                }
359            }
360
361            return len;
362        }
363
364        /**
365         * Returns <code>text</code>, modified, if necessary, to ensure that
366         * it ends with a token terminator (for example a space or comma).
367         * It is a method from the MultiAutoCompleteTextView.Tokenizer interface.
368         */
369        public CharSequence terminateToken(CharSequence text) {
370            int i = text.length();
371
372            while (i > 0 && text.charAt(i - 1) == ' ') {
373                i--;
374            }
375
376            char c;
377            if (i > 0 && ((c = text.charAt(i - 1)) == ',' || c == ';')) {
378                return text;
379            } else {
380                // Use the same delimiter the user just typed.
381                // This lets them have a mixture of commas and semicolons in their list.
382                String separator = mLastSeparator + " ";
383                if (text instanceof Spanned) {
384                    SpannableString sp = new SpannableString(text + separator);
385                    TextUtils.copySpansFrom((Spanned) text, 0, text.length(),
386                                            Object.class, sp, 0);
387                    return sp;
388                } else {
389                    return text + separator;
390                }
391            }
392        }
393
394        public List<String> getNumbers() {
395            Spanned sp = mList.getText();
396            int len = sp.length();
397            List<String> list = new ArrayList<String>();
398
399            int start = 0;
400            int i = 0;
401            while (i < len + 1) {
402                char c;
403                if ((i == len) || ((c = sp.charAt(i)) == ',') || (c == ';')) {
404                    if (i > start) {
405                        list.add(getNumberAt(sp, start, i, mContext));
406
407                        // calculate the recipients total length. This is so if the name contains
408                        // commas or semis, we'll skip over the whole name to the next
409                        // recipient, rather than parsing this single name into multiple
410                        // recipients.
411                        int spanLen = getSpanLength(sp, start, i, mContext);
412                        if (spanLen > i) {
413                            i = spanLen;
414                        }
415                    }
416
417                    i++;
418
419                    while ((i < len) && (sp.charAt(i) == ' ')) {
420                        i++;
421                    }
422
423                    start = i;
424                } else {
425                    i++;
426                }
427            }
428
429            return list;
430        }
431    }
432
433    static class RecipientContextMenuInfo implements ContextMenuInfo {
434        final Contact recipient;
435
436        RecipientContextMenuInfo(Contact r) {
437            recipient = r;
438        }
439    }
440}
441