1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.messaging.ui.contact;
17
18import android.content.Context;
19import android.database.Cursor;
20import android.graphics.Rect;
21import android.os.AsyncTask;
22import android.support.v7.appcompat.R;
23import android.text.Editable;
24import android.text.TextPaint;
25import android.text.TextWatcher;
26import android.text.util.Rfc822Tokenizer;
27import android.util.AttributeSet;
28import android.view.ContextThemeWrapper;
29import android.view.KeyEvent;
30import android.view.inputmethod.EditorInfo;
31import android.widget.TextView;
32
33import com.android.ex.chips.RecipientEditTextView;
34import com.android.ex.chips.RecipientEntry;
35import com.android.ex.chips.recipientchip.DrawableRecipientChip;
36import com.android.messaging.datamodel.data.ParticipantData;
37import com.android.messaging.util.ContactRecipientEntryUtils;
38import com.android.messaging.util.ContactUtil;
39import com.android.messaging.util.PhoneUtils;
40
41import java.util.ArrayList;
42import java.util.HashSet;
43import java.util.Set;
44import java.util.concurrent.Executor;
45import java.util.concurrent.Executors;
46
47/**
48 * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips.
49 * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of
50 * recipients in the form of a ParticipantData list.
51 */
52public class ContactRecipientAutoCompleteView extends RecipientEditTextView {
53    public interface ContactChipsChangeListener {
54        void onContactChipsChanged(int oldCount, int newCount);
55        void onInvalidContactChipsPruned(int prunedCount);
56        void onEntryComplete();
57    }
58
59    private final int mTextHeight;
60    private ContactChipsChangeListener mChipsChangeListener;
61
62    /**
63     * Watches changes in contact chips to determine possible state transitions.
64     */
65    private class ContactChipsWatcher implements TextWatcher {
66        /**
67         * Tracks the old chips count before text changes. Note that we currently don't compare
68         * the entire chip sets but just the cheaper-to-do before and after counts, because
69         * the chips view don't allow for replacing chips.
70         */
71        private int mLastChipsCount = 0;
72
73        @Override
74        public void onTextChanged(final CharSequence s, final int start, final int before,
75                final int count) {
76        }
77
78        @Override
79        public void beforeTextChanged(final CharSequence s, final int start, final int count,
80                final int after) {
81            // We don't take mLastChipsCount from here but from the last afterTextChanged() run.
82            // The reason is because at this point, any chip spans to be removed is already removed
83            // from s in the chips text view.
84        }
85
86        @Override
87        public void afterTextChanged(final Editable s) {
88            final int currentChipsCount = s.getSpans(0, s.length(),
89                    DrawableRecipientChip.class).length;
90            if (currentChipsCount != mLastChipsCount) {
91                // When a sanitizing task is running, we don't want to notify any chips count
92                // change, but we do want to track the last chip count.
93                if (mChipsChangeListener != null && mCurrentSanitizeTask == null) {
94                    mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount);
95                }
96                mLastChipsCount = currentChipsCount;
97            }
98        }
99    }
100
101    private static final String TEXT_HEIGHT_SAMPLE = "a";
102
103    public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) {
104        super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs);
105
106        // Get the height of the text, given the currently set font face and size.
107        final Rect textBounds = new Rect(0, 0, 0, 0);
108        final TextPaint paint = getPaint();
109        paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds);
110        mTextHeight = textBounds.height();
111
112        setTokenizer(new Rfc822Tokenizer());
113        addTextChangedListener(new ContactChipsWatcher());
114        setOnFocusListShrinkRecipients(false);
115
116        setBackground(context.getResources().getDrawable(
117                R.drawable.abc_textfield_search_default_mtrl_alpha));
118    }
119
120    public void setContactChipsListener(final ContactChipsChangeListener listener) {
121        mChipsChangeListener = listener;
122    }
123
124    /**
125     * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the
126     * chip actually replaced/removed on the UI thread.
127     */
128    private class ChipReplacementTuple {
129        public final DrawableRecipientChip removedChip;
130        public final RecipientEntry replacedChipEntry;
131
132        public ChipReplacementTuple(final DrawableRecipientChip removedChip,
133                final RecipientEntry replacedChipEntry) {
134            this.removedChip = removedChip;
135            this.replacedChipEntry = replacedChipEntry;
136        }
137    }
138
139    /**
140     * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new
141     * conversation with the given chips).
142     */
143    private class AsyncContactChipSanitizeTask extends
144            AsyncTask<Void, ChipReplacementTuple, Integer> {
145
146        @Override
147        protected Integer doInBackground(final Void... params) {
148            final DrawableRecipientChip[] recips = getText()
149                    .getSpans(0, getText().length(), DrawableRecipientChip.class);
150            int invalidChipsRemoved = 0;
151            for (final DrawableRecipientChip recipient : recips) {
152                final RecipientEntry entry = recipient.getEntry();
153                if (entry != null) {
154                    if (entry.isValid()) {
155                        if (RecipientEntry.isCreatedRecipient(entry.getContactId()) ||
156                                ContactRecipientEntryUtils.isSendToDestinationContact(entry)) {
157                            // This is a generated/send-to contact chip, try to look it up and
158                            // display a chip for the corresponding local contact.
159                            final Cursor lookupResult = ContactUtil.lookupDestination(getContext(),
160                                    entry.getDestination()).performSynchronousQuery();
161                            if (lookupResult != null && lookupResult.moveToNext()) {
162                                // Found a match, remove the generated entry and replace with
163                                // a better local entry.
164                                publishProgress(new ChipReplacementTuple(recipient,
165                                        ContactUtil.createRecipientEntryForPhoneQuery(
166                                                lookupResult, true)));
167                            } else if (PhoneUtils.isValidSmsMmsDestination(
168                                    entry.getDestination())){
169                                // No match was found, but we have a valid destination so let's at
170                                // least create an entry that shows an avatar.
171                                publishProgress(new ChipReplacementTuple(recipient,
172                                        ContactRecipientEntryUtils.constructNumberWithAvatarEntry(
173                                                entry.getDestination())));
174                            } else {
175                                // Not a valid contact. Remove and show an error.
176                                publishProgress(new ChipReplacementTuple(recipient, null));
177                                invalidChipsRemoved++;
178                            }
179                        }
180                    } else {
181                        publishProgress(new ChipReplacementTuple(recipient, null));
182                        invalidChipsRemoved++;
183                    }
184                }
185            }
186            return invalidChipsRemoved;
187        }
188
189        @Override
190        protected void onProgressUpdate(final ChipReplacementTuple... values) {
191            for (final ChipReplacementTuple tuple : values) {
192                if (tuple.removedChip != null) {
193                    final Editable text = getText();
194                    final int chipStart = text.getSpanStart(tuple.removedChip);
195                    final int chipEnd = text.getSpanEnd(tuple.removedChip);
196                    if (chipStart >= 0 && chipEnd >= 0) {
197                        text.delete(chipStart, chipEnd);
198                    }
199
200                    if (tuple.replacedChipEntry != null) {
201                        appendRecipientEntry(tuple.replacedChipEntry);
202                    }
203                }
204            }
205        }
206
207        @Override
208        protected void onPostExecute(final Integer invalidChipsRemoved) {
209            mCurrentSanitizeTask = null;
210            if (invalidChipsRemoved > 0) {
211                mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved);
212            }
213        }
214    }
215
216    /**
217     * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that
218     * all sanitization tasks are serially executed so as not to interfere with each other.
219     */
220    private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor();
221
222    private AsyncContactChipSanitizeTask mCurrentSanitizeTask;
223
224    /**
225     * Whenever the caller wants to start a new conversation with the list of chips we have,
226     * make sure we asynchronously:
227     * 1. Remove invalid chips.
228     * 2. Attempt to resolve unknown contacts to known local contacts.
229     * 3. Convert still unknown chips to chips with generated avatar.
230     *
231     * Note that we don't need to perform this synchronously since we can
232     * resolve any unknown contacts to local contacts when needed.
233     */
234    private void sanitizeContactChips() {
235        if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) {
236            mCurrentSanitizeTask.cancel(false);
237            mCurrentSanitizeTask = null;
238        }
239        mCurrentSanitizeTask = new AsyncContactChipSanitizeTask();
240        mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR);
241    }
242
243    /**
244     * Returns a list of ParticipantData from the entered chips in order to create
245     * new conversation.
246     */
247    public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() {
248        final DrawableRecipientChip[] recips = getText()
249                .getSpans(0, getText().length(), DrawableRecipientChip.class);
250        final ArrayList<ParticipantData> contacts =
251                new ArrayList<ParticipantData>(recips.length);
252        for (final DrawableRecipientChip recipient : recips) {
253            final RecipientEntry entry = recipient.getEntry();
254            if (entry != null && entry.isValid() && entry.getDestination() != null &&
255                    PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) {
256                contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry()));
257            }
258        }
259        sanitizeContactChips();
260        return contacts;
261    }
262
263    /**c
264     * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the
265     * consumer with determining quickly whether a contact is currently selected.
266     */
267    public Set<String> getSelectedDestinations() {
268        Set<String> set = new HashSet<String>();
269        final DrawableRecipientChip[] recips = getText()
270                .getSpans(0, getText().length(), DrawableRecipientChip.class);
271
272        for (final DrawableRecipientChip recipient : recips) {
273            final RecipientEntry entry = recipient.getEntry();
274            if (entry != null && entry.isValid() && entry.getDestination() != null) {
275                set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale(
276                        entry.getDestination()));
277            }
278        }
279        return set;
280    }
281
282    @Override
283    public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) {
284        if (actionId == EditorInfo.IME_ACTION_DONE) {
285            mChipsChangeListener.onEntryComplete();
286        }
287        return super.onEditorAction(view, actionId, event);
288    }
289}
290