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  */
16 package com.android.messaging.ui.contact;
17 
18 import android.content.Context;
19 import android.database.Cursor;
20 import android.graphics.Rect;
21 import android.os.AsyncTask;
22 import androidx.appcompat.R;
23 import android.text.Editable;
24 import android.text.TextPaint;
25 import android.text.TextWatcher;
26 import android.text.util.Rfc822Tokenizer;
27 import android.util.AttributeSet;
28 import android.view.ContextThemeWrapper;
29 import android.view.KeyEvent;
30 import android.view.inputmethod.EditorInfo;
31 import android.widget.TextView;
32 
33 import com.android.ex.chips.RecipientEditTextView;
34 import com.android.ex.chips.RecipientEntry;
35 import com.android.ex.chips.recipientchip.DrawableRecipientChip;
36 import com.android.messaging.datamodel.data.ParticipantData;
37 import com.android.messaging.util.ContactRecipientEntryUtils;
38 import com.android.messaging.util.ContactUtil;
39 import com.android.messaging.util.PhoneUtils;
40 
41 import java.util.ArrayList;
42 import java.util.HashSet;
43 import java.util.Set;
44 import java.util.concurrent.Executor;
45 import 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  */
52 public class ContactRecipientAutoCompleteView extends RecipientEditTextView {
53     public interface ContactChipsChangeListener {
onContactChipsChanged(int oldCount, int newCount)54         void onContactChipsChanged(int oldCount, int newCount);
onInvalidContactChipsPruned(int prunedCount)55         void onInvalidContactChipsPruned(int prunedCount);
onEntryComplete()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
onTextChanged(final CharSequence s, final int start, final int before, final int count)74         public void onTextChanged(final CharSequence s, final int start, final int before,
75                 final int count) {
76         }
77 
78         @Override
beforeTextChanged(final CharSequence s, final int start, final int count, final int after)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
afterTextChanged(final Editable s)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 
ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs)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 
setContactChipsListener(final ContactChipsChangeListener listener)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 
ChipReplacementTuple(final DrawableRecipientChip removedChip, final RecipientEntry replacedChipEntry)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
doInBackground(final Void... params)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                             try (final Cursor lookupResult =
160                                     ContactUtil.lookupDestination(
161                                                     getContext(), entry.getDestination())
162                                             .performSynchronousQuery()) {
163                                 if (lookupResult != null && lookupResult.moveToNext()) {
164                                     // Found a match, remove the generated entry and replace with a
165                                     // better local entry.
166                                     publishProgress(
167                                             new ChipReplacementTuple(
168                                                     recipient,
169                                                     ContactUtil.createRecipientEntryForPhoneQuery(
170                                                             lookupResult, true)));
171                                 } else if (PhoneUtils.isValidSmsMmsDestination(
172                                         entry.getDestination())) {
173                                     // No match was found, but we have a valid destination so let's
174                                     // at least create an entry that shows an avatar.
175                                     publishProgress(
176                                             new ChipReplacementTuple(
177                                                     recipient,
178                                                     ContactRecipientEntryUtils
179                                                             .constructNumberWithAvatarEntry(
180                                                                     entry.getDestination())));
181                                 } else {
182                                     // Not a valid contact. Remove and show an error.
183                                     publishProgress(new ChipReplacementTuple(recipient, null));
184                                     invalidChipsRemoved++;
185                                 }
186                             }
187                         }
188                     } else {
189                         publishProgress(new ChipReplacementTuple(recipient, null));
190                         invalidChipsRemoved++;
191                     }
192                 }
193             }
194             return invalidChipsRemoved;
195         }
196 
197         @Override
onProgressUpdate(final ChipReplacementTuple... values)198         protected void onProgressUpdate(final ChipReplacementTuple... values) {
199             for (final ChipReplacementTuple tuple : values) {
200                 if (tuple.removedChip != null) {
201                     final Editable text = getText();
202                     final int chipStart = text.getSpanStart(tuple.removedChip);
203                     final int chipEnd = text.getSpanEnd(tuple.removedChip);
204                     if (chipStart >= 0 && chipEnd >= 0) {
205                         text.delete(chipStart, chipEnd);
206                     }
207 
208                     if (tuple.replacedChipEntry != null) {
209                         appendRecipientEntry(tuple.replacedChipEntry);
210                     }
211                 }
212             }
213         }
214 
215         @Override
onPostExecute(final Integer invalidChipsRemoved)216         protected void onPostExecute(final Integer invalidChipsRemoved) {
217             mCurrentSanitizeTask = null;
218             if (invalidChipsRemoved > 0) {
219                 mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved);
220             }
221         }
222     }
223 
224     /**
225      * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that
226      * all sanitization tasks are serially executed so as not to interfere with each other.
227      */
228     private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor();
229 
230     private AsyncContactChipSanitizeTask mCurrentSanitizeTask;
231 
232     /**
233      * Whenever the caller wants to start a new conversation with the list of chips we have,
234      * make sure we asynchronously:
235      * 1. Remove invalid chips.
236      * 2. Attempt to resolve unknown contacts to known local contacts.
237      * 3. Convert still unknown chips to chips with generated avatar.
238      *
239      * Note that we don't need to perform this synchronously since we can
240      * resolve any unknown contacts to local contacts when needed.
241      */
sanitizeContactChips()242     private void sanitizeContactChips() {
243         if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) {
244             mCurrentSanitizeTask.cancel(false);
245             mCurrentSanitizeTask = null;
246         }
247         mCurrentSanitizeTask = new AsyncContactChipSanitizeTask();
248         mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR);
249     }
250 
251     /**
252      * Returns a list of ParticipantData from the entered chips in order to create
253      * new conversation.
254      */
getRecipientParticipantDataForConversationCreation()255     public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() {
256         final DrawableRecipientChip[] recips = getText()
257                 .getSpans(0, getText().length(), DrawableRecipientChip.class);
258         final ArrayList<ParticipantData> contacts =
259                 new ArrayList<ParticipantData>(recips.length);
260         for (final DrawableRecipientChip recipient : recips) {
261             final RecipientEntry entry = recipient.getEntry();
262             if (entry != null && entry.isValid() && entry.getDestination() != null &&
263                     PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) {
264                 contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry()));
265             }
266         }
267         sanitizeContactChips();
268         return contacts;
269     }
270 
271     /**c
272      * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the
273      * consumer with determining quickly whether a contact is currently selected.
274      */
getSelectedDestinations()275     public Set<String> getSelectedDestinations() {
276         Set<String> set = new HashSet<String>();
277         final DrawableRecipientChip[] recips = getText()
278                 .getSpans(0, getText().length(), DrawableRecipientChip.class);
279 
280         for (final DrawableRecipientChip recipient : recips) {
281             final RecipientEntry entry = recipient.getEntry();
282             if (entry != null && entry.isValid() && entry.getDestination() != null) {
283                 set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale(
284                         entry.getDestination()));
285             }
286         }
287         return set;
288     }
289 
290     @Override
onEditorAction(final TextView view, final int actionId, final KeyEvent event)291     public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) {
292         if (actionId == EditorInfo.IME_ACTION_DONE) {
293             mChipsChangeListener.onEntryComplete();
294         }
295         return super.onEditorAction(view, actionId, event);
296     }
297 }
298