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.database.MergeCursor;
21 import androidx.core.util.Pair;
22 import android.text.TextUtils;
23 import android.text.util.Rfc822Token;
24 import android.text.util.Rfc822Tokenizer;
25 import android.view.LayoutInflater;
26 import android.view.View;
27 import android.view.ViewGroup;
28 import android.widget.Filter;
29 import android.widget.TextView;
30 
31 import com.android.ex.chips.BaseRecipientAdapter;
32 import com.android.ex.chips.RecipientAlternatesAdapter;
33 import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback;
34 import com.android.ex.chips.RecipientEntry;
35 import com.android.messaging.R;
36 import com.android.messaging.util.Assert;
37 import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
38 import com.android.messaging.util.BugleGservices;
39 import com.android.messaging.util.BugleGservicesKeys;
40 import com.android.messaging.util.ContactRecipientEntryUtils;
41 import com.android.messaging.util.ContactUtil;
42 import com.android.messaging.util.OsUtil;
43 import com.android.messaging.util.PhoneUtils;
44 
45 import java.text.Collator;
46 import java.util.ArrayList;
47 import java.util.Collections;
48 import java.util.Comparator;
49 import java.util.HashMap;
50 import java.util.HashSet;
51 import java.util.List;
52 import java.util.Locale;
53 import java.util.Map;
54 
55 /**
56  * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle,
57  * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and
58  * contact lookup that relies on ContactUtil. It provides data source and filtering ability
59  * for {@link ContactRecipientAutoCompleteView}
60  */
61 public final class ContactRecipientAdapter extends BaseRecipientAdapter {
62     private static final int WORD_DIRECTORY_HEADER_POS_NONE = -1;
63     /**
64      * Stores the index of work directory header.
65      */
66     private int mWorkDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE;
67     private final LayoutInflater mInflater;
68 
69     /**
70      * Type of directory entry.
71      */
72     private static final int ENTRY_TYPE_DIRECTORY = RecipientEntry.ENTRY_TYPE_SIZE;
73 
ContactRecipientAdapter(final Context context, final ContactListItemView.HostInterface clivHost)74     public ContactRecipientAdapter(final Context context,
75             final ContactListItemView.HostInterface clivHost) {
76         this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost);
77     }
78 
ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, final int queryMode, final ContactListItemView.HostInterface clivHost)79     public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount,
80             final int queryMode, final ContactListItemView.HostInterface clivHost) {
81         super(context, preferredMaxResultCount, queryMode);
82         setPhotoManager(new ContactRecipientPhotoManager(context, clivHost));
83         mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
84     }
85 
86     @Override
forceShowAddress()87     public boolean forceShowAddress() {
88         // We should always use the SingleRecipientAddressAdapter
89         // And never use the RecipientAlternatesAdapter
90         return true;
91     }
92 
93     @Override
getFilter()94     public Filter getFilter() {
95         return new ContactFilter();
96     }
97 
98     /**
99      * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete
100      * results.
101      */
102     public class ContactFilter extends Filter {
103 
104         // Used to sort filtered contacts when it has combined results from email and phone.
105         private final RecipientEntryComparator mComparator = new RecipientEntryComparator();
106 
107         /**
108          * Returns a cursor containing the filtered results in contacts given the search text,
109          * and a boolean indicating whether the results are sorted.
110          *
111          * The queries are synchronously performed since this is not run on the main thread.
112          *
113          * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS.
114          * If this is the case, perform two queries on phone number followed by email and
115          * return the merged results.
116          */
117         @DoesNotRunOnMainThread
getFilteredResultsCursor(final String searchText)118         private CursorResult getFilteredResultsCursor(final String searchText) {
119             Assert.isNotMainThread();
120             if (BugleGservices.get().getBoolean(
121                     BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS,
122                     BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) {
123 
124                 final Cursor personalFilterPhonesCursor = ContactUtil
125                         .filterPhones(getContext(), searchText).performSynchronousQuery();
126                 final Cursor personalFilterEmailsCursor = ContactUtil
127                         .filterEmails(getContext(), searchText).performSynchronousQuery();
128                 final Cursor personalCursor = new MergeCursor(
129                         new Cursor[]{personalFilterEmailsCursor, personalFilterPhonesCursor});
130                 final CursorResult cursorResult =
131                         new CursorResult(personalCursor, false /* sorted */);
132                 if (OsUtil.isAtLeastN()) {
133                     // Including enterprise result starting from N.
134                     final Cursor enterpriseFilterPhonesCursor = ContactUtil.filterPhonesEnterprise(
135                             getContext(), searchText).performSynchronousQuery();
136                     final Cursor enterpriseFilterEmailsCursor = ContactUtil.filterEmailsEnterprise(
137                             getContext(), searchText).performSynchronousQuery();
138                     final Cursor enterpriseCursor = new MergeCursor(
139                             new Cursor[]{enterpriseFilterEmailsCursor,
140                                     enterpriseFilterPhonesCursor});
141                     cursorResult.enterpriseCursor = enterpriseCursor;
142                 }
143                 return cursorResult;
144             } else {
145                 final Cursor personalFilterDestinationCursor = ContactUtil
146                         .filterDestination(getContext(), searchText).performSynchronousQuery();
147                 final CursorResult cursorResult = new CursorResult(personalFilterDestinationCursor,
148                         true);
149                 if (OsUtil.isAtLeastN()) {
150                     // Including enterprise result starting from N.
151                     final Cursor enterpriseFilterDestinationCursor = ContactUtil
152                             .filterDestinationEnterprise(getContext(), searchText)
153                             .performSynchronousQuery();
154                     cursorResult.enterpriseCursor = enterpriseFilterDestinationCursor;
155                 }
156                 return cursorResult;
157             }
158         }
159 
160         @Override
performFiltering(final CharSequence constraint)161         protected FilterResults performFiltering(final CharSequence constraint) {
162             Assert.isNotMainThread();
163             final FilterResults results = new FilterResults();
164 
165             // No query, return empty results.
166             if (TextUtils.isEmpty(constraint)) {
167                 clearTempEntries();
168                 return results;
169             }
170 
171             final String searchText = constraint.toString();
172 
173             // Query for auto-complete results, since performFiltering() is not done on the
174             // main thread, perform the cursor loader queries directly.
175 
176             final CursorResult cursorResult = getFilteredResultsCursor(searchText);
177             final List<RecipientEntry> entries = new ArrayList<>();
178 
179             // First check if the constraint is a valid SMS destination. If so, add the
180             // destination as a suggestion item to the drop down.
181             if (PhoneUtils.isValidSmsMmsDestination(searchText)) {
182                 entries.add(ContactRecipientEntryUtils
183                         .constructSendToDestinationEntry(searchText));
184             }
185 
186             // Only show work directory header if more than one result in work directory.
187             int workDirectoryHeaderPos = WORD_DIRECTORY_HEADER_POS_NONE;
188             if (cursorResult.enterpriseCursor != null
189                     && cursorResult.enterpriseCursor.getCount() > 0) {
190                 if (cursorResult.personalCursor != null) {
191                     workDirectoryHeaderPos = entries.size();
192                     workDirectoryHeaderPos += cursorResult.personalCursor.getCount();
193                 }
194             }
195 
196             final Cursor[] cursors = new Cursor[]{cursorResult.personalCursor,
197                     cursorResult.enterpriseCursor};
198             for (Cursor cursor : cursors) {
199                 if (cursor != null) {
200                     try {
201                         final List<RecipientEntry> tempEntries = new ArrayList<>();
202                         HashSet<Long> existingContactIds = new HashSet<>();
203                         while (cursor.moveToNext()) {
204                             // Make sure there's only one first-level contact (i.e. contact for
205                             // which we show the avatar picture and name) for every contact id.
206                             final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID);
207                             final boolean isFirstLevel = !existingContactIds.contains(contactId);
208                             if (isFirstLevel) {
209                                 existingContactIds.add(contactId);
210                             }
211                             tempEntries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor,
212                                     isFirstLevel));
213                         }
214 
215                         if (!cursorResult.isSorted) {
216                             Collections.sort(tempEntries, mComparator);
217                         }
218                         entries.addAll(tempEntries);
219                     } finally {
220                         cursor.close();
221                     }
222                 }
223             }
224             results.values = new ContactReceipientFilterResult(entries, workDirectoryHeaderPos);
225             results.count = 1;
226             return results;
227         }
228 
229         @Override
publishResults(final CharSequence constraint, final FilterResults results)230         protected void publishResults(final CharSequence constraint, final FilterResults results) {
231             mCurrentConstraint = constraint;
232             clearTempEntries();
233 
234             final ContactReceipientFilterResult contactReceipientFilterResult
235                     = (ContactReceipientFilterResult) results.values;
236             if (contactReceipientFilterResult != null) {
237                 mWorkDirectoryHeaderPos = contactReceipientFilterResult.workDirectoryPos;
238                 if (contactReceipientFilterResult.recipientEntries != null) {
239                     updateEntries(contactReceipientFilterResult.recipientEntries);
240                 } else {
241                     updateEntries(Collections.<RecipientEntry>emptyList());
242                 }
243             }
244         }
245 
246         private class RecipientEntryComparator implements Comparator<RecipientEntry> {
247 
248             private final Collator mCollator;
249 
RecipientEntryComparator()250             public RecipientEntryComparator() {
251                 mCollator = Collator.getInstance(Locale.getDefault());
252                 mCollator.setStrength(Collator.PRIMARY);
253             }
254 
255             /**
256              * Compare two RecipientEntry's, first by locale-aware display name comparison, then by
257              * contact id comparison, finally by first-level-ness comparison.
258              */
259             @Override
compare(RecipientEntry lhs, RecipientEntry rhs)260             public int compare(RecipientEntry lhs, RecipientEntry rhs) {
261                 // Send-to-destinations always appear before everything else.
262                 final boolean sendToLhs = ContactRecipientEntryUtils
263                         .isSendToDestinationContact(lhs);
264                 final boolean sendToRhs = ContactRecipientEntryUtils
265                         .isSendToDestinationContact(lhs);
266                 if (sendToLhs != sendToRhs) {
267                     if (sendToLhs) {
268                         return -1;
269                     } else if (sendToRhs) {
270                         return 1;
271                     }
272                 }
273 
274                 final int displayNameCompare = mCollator.compare(lhs.getDisplayName(),
275                         rhs.getDisplayName());
276                 if (displayNameCompare != 0) {
277                     return displayNameCompare;
278                 }
279 
280                 // Long.compare could accomplish the following three lines, but this is only
281                 // available in API 19+
282                 final long lhsContactId = lhs.getContactId();
283                 final long rhsContactId = rhs.getContactId();
284                 final int contactCompare = lhsContactId < rhsContactId ? -1 :
285                         (lhsContactId == rhsContactId ? 0 : 1);
286                 if (contactCompare != 0) {
287                     return contactCompare;
288                 }
289 
290                 // These are the same contact. Make sure first-level contacts always
291                 // appear at the front.
292                 if (lhs.isFirstLevel()) {
293                     return -1;
294                 } else if (rhs.isFirstLevel()) {
295                     return 1;
296                 } else {
297                     return 0;
298                 }
299             }
300         }
301 
302         private class CursorResult {
303 
304             public final Cursor personalCursor;
305 
306             public Cursor enterpriseCursor;
307 
308             public final boolean isSorted;
309 
310             public CursorResult(Cursor personalCursor, boolean isSorted) {
311                 this.personalCursor = personalCursor;
312                 this.isSorted = isSorted;
313             }
314         }
315 
316         private class ContactReceipientFilterResult {
317             /**
318              * Recipient entries in all directories.
319              */
320             public final List<RecipientEntry> recipientEntries;
321 
322             /**
323              * Index of row that showing work directory header.
324              */
325             public final int workDirectoryPos;
326 
327             public ContactReceipientFilterResult(List<RecipientEntry> recipientEntries,
328                     int workDirectoryPos) {
329                 this.recipientEntries = recipientEntries;
330                 this.workDirectoryPos = workDirectoryPos;
331             }
332         }
333     }
334 
335     /**
336      * Called when we need to substitute temporary recipient chips with better alternatives.
337      * For example, if a list of comma-delimited phone numbers are pasted into the edit box,
338      * we want to be able to look up in the ContactUtil for exact matches and get contact
339      * details such as name and photo thumbnail for the contact to display a better chip.
340      */
341     @Override
342     public void getMatchingRecipients(final ArrayList<String> inAddresses,
343             final RecipientMatchCallback callback) {
344         final int addressesSize = Math.min(
345                 RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size());
346         final HashSet<String> addresses = new HashSet<String>();
347         for (int i = 0; i < addressesSize; i++) {
348             final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase());
349             addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i));
350         }
351 
352         final Map<String, RecipientEntry> recipientEntries =
353                 new HashMap<String, RecipientEntry>();
354         // query for each address
355         for (final String address : addresses) {
356             final Cursor cursor = ContactUtil.lookupDestination(getContext(), address)
357                     .performSynchronousQuery();
358             if (cursor != null) {
359                 try {
360                     if (cursor.moveToNext()) {
361                         // There may be multiple matches to the same number, always take the
362                         // first match.
363                         // TODO: May need to consider if there's an existing conversation
364                         // that matches this particular contact and prioritize that contact.
365                         final RecipientEntry entry =
366                                 ContactUtil.createRecipientEntryForPhoneQuery(cursor, true);
367                         recipientEntries.put(address, entry);
368                     }
369 
370                 } finally {
371                     cursor.close();
372                 }
373             }
374         }
375 
376         // report matches
377         callback.matchesFound(recipientEntries);
378     }
379 
380     /**
381      * We handle directory header here and then delegate the work of creating recipient views to
382      * the {@link BaseRecipientAdapter}. Please notice that we need to fix the position
383      * before passing to {@link BaseRecipientAdapter} because it is not aware of the existence of
384      * directory headers.
385      */
386     @Override
387     public View getView(int position, View convertView, ViewGroup parent) {
388         TextView textView;
389         if (isDirectoryEntry(position)) {
390             if (convertView == null) {
391                 textView = (TextView) mInflater.inflate(R.layout.work_directory_header, parent,
392                         false);
393             } else {
394                 textView = (TextView) convertView;
395             }
396             return textView;
397         }
398         return super.getView(fixPosition(position), convertView, parent);
399     }
400 
401     @Override
402     public RecipientEntry getItem(int position) {
403         if (isDirectoryEntry(position)) {
404             return null;
405         }
406         return super.getItem(fixPosition(position));
407     }
408 
409     @Override
410     public int getViewTypeCount() {
411         return RecipientEntry.ENTRY_TYPE_SIZE + 1;
412     }
413 
414     @Override
415     public int getItemViewType(int position) {
416         if (isDirectoryEntry(position)) {
417             return ENTRY_TYPE_DIRECTORY;
418         }
419         return super.getItemViewType(fixPosition(position));
420     }
421 
422     @Override
423     public boolean isEnabled(int position) {
424         if (isDirectoryEntry(position)) {
425             return false;
426         }
427         return super.isEnabled(fixPosition(position));
428     }
429 
430     @Override
431     public int getCount() {
432         return super.getCount() + ((hasWorkDirectoryHeader()) ? 1 : 0);
433     }
434 
435     private boolean isDirectoryEntry(int position) {
436         return position == mWorkDirectoryHeaderPos;
437     }
438 
439     /**
440      * @return the position of items without counting directory headers.
441      */
442     private int fixPosition(int position) {
443         if (hasWorkDirectoryHeader()) {
444             Assert.isTrue(position != mWorkDirectoryHeaderPos);
445             if (position > mWorkDirectoryHeaderPos) {
446                 return position - 1;
447             }
448         }
449         return position;
450     }
451 
hasWorkDirectoryHeader()452     private boolean hasWorkDirectoryHeader() {
453         return mWorkDirectoryHeaderPos != WORD_DIRECTORY_HEADER_POS_NONE;
454     }
455 
456 }
457