1 // Copyright 2018 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.components.browser_ui.contacts_picker;
6 
7 import android.content.ContentResolver;
8 import android.content.Context;
9 import android.text.TextUtils;
10 import android.view.LayoutInflater;
11 import android.view.View;
12 import android.view.ViewGroup;
13 
14 import androidx.annotation.CallSuper;
15 import androidx.annotation.IntDef;
16 import androidx.annotation.Nullable;
17 import androidx.annotation.VisibleForTesting;
18 import androidx.recyclerview.widget.RecyclerView;
19 import androidx.recyclerview.widget.RecyclerView.Adapter;
20 
21 import org.chromium.base.task.AsyncTask;
22 
23 import java.lang.annotation.Retention;
24 import java.lang.annotation.RetentionPolicy;
25 import java.util.ArrayList;
26 import java.util.List;
27 import java.util.Locale;
28 
29 /**
30  * A data adapter for the Contacts Picker.
31  *
32  * This class is abstract and embedders must specialize it to provide access to the active
33  * user's contact information.
34  */
35 public abstract class PickerAdapter extends Adapter<RecyclerView.ViewHolder>
36         implements ContactsFetcherWorkerTask.ContactsRetrievedCallback,
37                    TopView.ChipToggledCallback {
38     /**
39      * A ViewHolder for the top-most view in the RecyclerView. The view it contains has a
40      * checkbox and some multi-line text that goes with it, so clicks on either text line
41      * should be treated as clicks for the checkbox (hence the onclick forwarding).
42      */
43     class TopViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
44         TopView mItemView;
45 
TopViewHolder(TopView itemView)46         public TopViewHolder(TopView itemView) {
47             super(itemView);
48             mItemView = itemView;
49             mItemView.setOnClickListener(this);
50         }
51 
52         @Override
onClick(View view)53         public void onClick(View view) {
54             // TODO(finnur): Make the explanation text non-clickable.
55             mItemView.toggle();
56         }
57     }
58 
59     /**
60      * The types of filters supported.
61      */
62     @Retention(RetentionPolicy.SOURCE)
63     @IntDef({FilterType.NAMES, FilterType.EMAILS, FilterType.TELEPHONES, FilterType.ADDRESSES,
64             FilterType.ICONS})
65     public @interface FilterType {
66         int NAMES = 0;
67         int EMAILS = 1;
68         int TELEPHONES = 2;
69         int ADDRESSES = 3;
70         int ICONS = 4;
71     }
72 
73     /**
74      * The types of views supported.
75      */
76     @Retention(RetentionPolicy.SOURCE)
77     @IntDef({ViewType.SELECT_ALL_CHECKBOX, ViewType.CONTACT_DETAILS})
78     private @interface ViewType {
79         int SELECT_ALL_CHECKBOX = 0;
80         int CONTACT_DETAILS = 1;
81     }
82 
83     // The current context to use.
84     private Context mContext;
85 
86     // The category view to use to show the contacts.
87     private PickerCategoryView mCategoryView;
88 
89     // The view at the top of the RecyclerView (disclaimer and select all functionality).
90     private TopView mTopView;
91 
92     // The origin the data will be shared with, formatted for display with the scheme omitted.
93     private String mFormattedOrigin;
94 
95     // The content resolver to query data from.
96     private ContentResolver mContentResolver;
97 
98     // The full list of all registered contacts on the device.
99     private ArrayList<ContactDetails> mContactDetails;
100 
101     // The email address of the owner of the device.
102     @Nullable
103     private String mOwnerEmail;
104 
105     // The async worker task to use for fetching the contact details.
106     private ContactsFetcherWorkerTask mWorkerTask;
107 
108     // Whether the user has switched to search mode.
109     private boolean mSearchMode;
110 
111     // A list of search result indices into the larger data set.
112     private ArrayList<Integer> mSearchResults;
113 
114     // Whether to include addresses in the returned results.
115     private static boolean sIncludeAddresses;
116 
117     // Whether to include names in the returned results.
118     private static boolean sIncludeNames;
119 
120     // Whether to include emails in the returned results.
121     private static boolean sIncludeEmails;
122 
123     // Whether to include telephone numbers in the returned results.
124     private static boolean sIncludeTelephones;
125 
126     // Whether to include icons in the returned results.
127     private static boolean sIncludeIcons;
128 
129     // A list of contacts to use for testing (instead of querying Android).
130     private static ArrayList<ContactDetails> sTestContacts;
131 
132     // An owner email to use when testing.
133     private static String sTestOwnerEmail;
134 
135     /**
136      * The PickerAdapter constructor.
137      * @param categoryView The category view to use to show the contacts.
138      * @param context The current context.
139      * @param formattedOrigin The origin the data will be shared with.
140      */
141     @CallSuper
init(PickerCategoryView categoryView, Context context, String formattedOrigin)142     public void init(PickerCategoryView categoryView, Context context, String formattedOrigin) {
143         mContext = context;
144         mCategoryView = categoryView;
145         mContentResolver = context.getContentResolver();
146         mFormattedOrigin = formattedOrigin;
147         sIncludeAddresses = true;
148         sIncludeNames = true;
149         sIncludeEmails = true;
150         sIncludeTelephones = true;
151         sIncludeIcons = true;
152 
153         if (getAllContacts() == null && sTestContacts == null) {
154             mWorkerTask = new ContactsFetcherWorkerTask(context, this, mCategoryView.includeNames,
155                     mCategoryView.includeEmails, mCategoryView.includeTel,
156                     mCategoryView.includeAddresses);
157             mWorkerTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
158         } else {
159             contactsRetrieved(sTestContacts);
160         }
161     }
162 
163     /**
164      * Set whether the user has switched to search mode.
165      * @param searchMode True when we are in search mode.
166      */
setSearchMode(boolean searchMode)167     public void setSearchMode(boolean searchMode) {
168         mSearchMode = searchMode;
169         notifyDataSetChanged();
170     }
171 
172     /**
173      * Sets the search query (filter) for the contact list. Filtering is by display name.
174      * @param query The search term to use.
175      */
setSearchString(String query)176     public void setSearchString(String query) {
177         if (query.equals("")) {
178             if (mSearchResults == null) return;
179             mSearchResults.clear();
180             mSearchResults = null;
181         } else {
182             mSearchResults = new ArrayList<Integer>();
183             Integer count = 0;
184             String query_lower = query.toLowerCase(Locale.getDefault());
185             for (ContactDetails contact : mContactDetails) {
186                 if (contact.getDisplayName().toLowerCase(Locale.getDefault()).contains(query_lower)
187                         || contact.getContactDetailsAsString(includesAddresses(), includesEmails(),
188                                           includesTelephones())
189                                    .toLowerCase(Locale.getDefault())
190                                    .contains(query_lower)) {
191                     mSearchResults.add(count);
192                 }
193                 count++;
194             }
195         }
196         notifyDataSetChanged();
197     }
198 
199     /**
200      * Fetches all known contacts.
201      * @return The contact list as an array.
202      */
getAllContacts()203     public ArrayList<ContactDetails> getAllContacts() {
204         return mContactDetails;
205     }
206 
getOwnerEmail()207     protected String getOwnerEmail() {
208         return mOwnerEmail;
209     }
210 
update()211     protected void update() {
212         if (mTopView != null) mTopView.updateContactCount(mContactDetails.size());
213         notifyDataSetChanged();
214     }
215 
216     // Abstract methods:
217 
218     /**
219      * Called to get the email for the current user.
220      * The default is null, but some embedder-specific specializations may override this method to
221      * facilitate showing the owner's contact card at the top of the picker.
222      * @return the email address of the current user/owner.
223      */
224     @Nullable
findOwnerEmail()225     protected abstract String findOwnerEmail();
226 
227     /**
228      * Called to add an entry which represents the current user to the given list.
229      * As with {@link #findOwnerEmail}, embedders may override this to make sure the current user's
230      * contact card is shown, or may no-op.
231      * @param contacts the list which is missing an entry for the active user, and to which such an
232      *         entry should be pre-pended.
233      */
addOwnerInfoToContacts(ArrayList<ContactDetails> contacts)234     protected abstract void addOwnerInfoToContacts(ArrayList<ContactDetails> contacts);
235 
236     // ContactsFetcherWorkerTask.ContactsRetrievedCallback:
237 
238     @Override
contactsRetrieved(ArrayList<ContactDetails> contacts)239     public void contactsRetrieved(ArrayList<ContactDetails> contacts) {
240         mOwnerEmail = sTestOwnerEmail != null ? sTestOwnerEmail : findOwnerEmail();
241 
242         if (!processOwnerInfo(contacts, mOwnerEmail)) addOwnerInfoToContacts(contacts);
243         mContactDetails = contacts;
244         update();
245     }
246 
247     // RecyclerView.Adapter:
248 
249     @Override
getItemViewType(int position)250     public int getItemViewType(int position) {
251         if (position == 0 && !mSearchMode) return ViewType.SELECT_ALL_CHECKBOX;
252         return ViewType.CONTACT_DETAILS;
253     }
254 
255     @Override
onCreateViewHolder(ViewGroup parent, int viewType)256     public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
257         switch (viewType) {
258             case ViewType.SELECT_ALL_CHECKBOX: {
259                 mTopView = (TopView) LayoutInflater.from(parent.getContext())
260                                    .inflate(R.layout.top_view, parent, false);
261                 mTopView.setSiteString(mFormattedOrigin);
262                 mTopView.registerSelectAllCallback(mCategoryView);
263                 mTopView.registerChipToggledCallback(this);
264                 mTopView.updateCheckboxVisibility(mCategoryView.multiSelectionAllowed());
265                 mTopView.updateChipVisibility(mCategoryView.includeNames,
266                         mCategoryView.includeAddresses, mCategoryView.includeEmails,
267                         mCategoryView.includeTel, mCategoryView.includeIcons);
268                 mCategoryView.setTopView(mTopView);
269                 if (mContactDetails != null) mTopView.updateContactCount(mContactDetails.size());
270                 return new TopViewHolder(mTopView);
271             }
272             case ViewType.CONTACT_DETAILS: {
273                 ContactView itemView = (ContactView) LayoutInflater.from(parent.getContext())
274                                                .inflate(R.layout.contact_view, parent, false);
275                 itemView.setCategoryView(mCategoryView);
276                 return new ContactViewHolder(itemView, mCategoryView, mContentResolver);
277             }
278         }
279         return null;
280     }
281 
282     @Override
onBindViewHolder(RecyclerView.ViewHolder holder, int position)283     public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
284         switch (holder.getItemViewType()) {
285             case ViewType.SELECT_ALL_CHECKBOX:
286                 // There's no need to bind the Select All view.
287                 return;
288             case ViewType.CONTACT_DETAILS:
289                 ContactViewHolder contactHolder = (ContactViewHolder) holder;
290                 ContactDetails contact;
291                 if (!mSearchMode || mSearchResults == null) {
292                     // Subtract one because the first view is the Select All checkbox when not in
293                     // search mode.
294                     contact = mContactDetails.get(position - (mSearchMode ? 0 : 1));
295                 } else {
296                     Integer index = mSearchResults.get(position);
297                     contact = mContactDetails.get(index);
298                 }
299 
300                 contactHolder.setContactDetails(contact);
301         }
302     }
303 
304     @Override
305     // This will return how many items the RecyclerView should show, which can be a subset of
306     // contacts when in search mode. This function also includes the Select All checkbox (which is
307     // not a contact, obviously). To get the total number of contacts use getAllContacts().size()
308     // instead.
getItemCount()309     public int getItemCount() {
310         if (mSearchResults != null) return mSearchResults.size();
311         if (mContactDetails == null || mContactDetails.size() == 0) return 0;
312         // Add one entry to account for the Select All checkbox, when not searching.
313         return mContactDetails.size() + (mSearchMode ? 0 : 1);
314     }
315 
316     // TopView.ChipToggledCallback:
317 
318     @Override
onChipToggled(@ilterType int chip)319     public void onChipToggled(@FilterType int chip) {
320         switch (chip) {
321             case FilterType.NAMES:
322                 sIncludeNames = !sIncludeNames;
323                 break;
324             case FilterType.ADDRESSES:
325                 sIncludeAddresses = !sIncludeAddresses;
326                 break;
327             case FilterType.EMAILS:
328                 sIncludeEmails = !sIncludeEmails;
329                 break;
330             case FilterType.TELEPHONES:
331                 sIncludeTelephones = !sIncludeTelephones;
332                 break;
333             case FilterType.ICONS:
334                 sIncludeIcons = !sIncludeIcons;
335                 break;
336             default:
337                 assert false;
338         }
339 
340         notifyDataSetChanged();
341     }
342 
343     /**
344      * Returns true unless the adapter is filtering out addresses.
345      */
includesAddresses()346     public static boolean includesAddresses() {
347         return sIncludeAddresses;
348     }
349 
350     /**
351      * Returns true unless the adapter is filtering out names.
352      */
includesNames()353     public static boolean includesNames() {
354         return sIncludeNames;
355     }
356 
357     /**
358      * Returns true unless the adapter is filtering out emails.
359      */
includesEmails()360     public static boolean includesEmails() {
361         return sIncludeEmails;
362     }
363 
364     /**
365      * Returns true unless the adapter is filtering out telephone numbers.
366      */
includesTelephones()367     public static boolean includesTelephones() {
368         return sIncludeTelephones;
369     }
370 
371     /**
372      * Returns true unless the adapter is filtering out icons.
373      */
includesIcons()374     public static boolean includesIcons() {
375         return sIncludeIcons;
376     }
377 
378     /**
379      * Sets a list of contacts to use as data for the dialog, and the owner email. For testing use
380      * only.
381      */
382     @VisibleForTesting
setTestContactsAndOwner( ArrayList<ContactDetails> contacts, String ownerEmail)383     public static void setTestContactsAndOwner(
384             ArrayList<ContactDetails> contacts, String ownerEmail) {
385         sTestContacts = contacts;
386         sTestOwnerEmail = ownerEmail;
387     }
388 
389     /**
390      * Attempts to figure out if the owner of the device is listed in the available contact details.
391      * If so move it to the top of the list. If not found, returns false.
392      * @return Returns true if processing is complete, false if waiting on asynchronous fetching of
393      *         missing data for the owner info.
394      */
processOwnerInfo(ArrayList<ContactDetails> contacts, String ownerEmail)395     private static boolean processOwnerInfo(ArrayList<ContactDetails> contacts, String ownerEmail) {
396         if (ownerEmail == null) {
397             return true;
398         }
399 
400         ArrayList<Integer> matches = new ArrayList<Integer>();
401         for (int i = 0; i < contacts.size(); ++i) {
402             List<String> emails = contacts.get(i).getEmails();
403             for (int y = 0; y < emails.size(); ++y) {
404                 if (TextUtils.equals(emails.get(y), ownerEmail)) {
405                     matches.add(i);
406                     break;
407                 }
408             }
409         }
410 
411         if (matches.size() == 0) {
412             // No match was found, return false so that a record can be synthesized.
413             return false;
414         }
415 
416         // Move the contacts that match owner email to the top of the list.
417         for (int i = 0; i < matches.size(); ++i) {
418             int match = matches.get(i);
419             ContactDetails contact = contacts.get(match);
420             contact.setIsSelf(true);
421             contacts.remove(match);
422             contacts.add(i, contact);
423         }
424         return true;
425     }
426 }
427