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 java.util.ArrayList;
21import java.util.List;
22
23import android.content.Context;
24import android.provider.Telephony.Mms;
25import android.telephony.PhoneNumberUtils;
26import android.text.Annotation;
27import android.text.Editable;
28import android.text.Layout;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.Spanned;
32import android.text.TextUtils;
33import android.text.TextWatcher;
34import android.text.util.Rfc822Token;
35import android.text.util.Rfc822Tokenizer;
36import android.util.AttributeSet;
37import android.view.ContextMenu.ContextMenuInfo;
38import android.view.MotionEvent;
39import android.view.View;
40import android.view.inputmethod.EditorInfo;
41import android.widget.AdapterView;
42import android.widget.MultiAutoCompleteTextView;
43
44import com.android.ex.chips.RecipientEditTextView;
45import com.android.mms.MmsConfig;
46import com.android.mms.data.Contact;
47import com.android.mms.data.ContactList;
48
49/**
50 * Provide UI for editing the recipients of multi-media messages.
51 */
52public 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