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 
17 package com.android.contacts.list;
18 
19 import android.content.Context;
20 import android.database.Cursor;
21 import android.graphics.drawable.Drawable;
22 import android.icu.text.MessageFormat;
23 import android.os.Bundle;
24 import android.provider.ContactsContract;
25 import androidx.core.view.ViewCompat;
26 import android.util.Log;
27 import android.view.LayoutInflater;
28 import android.view.View;
29 import android.view.ViewGroup;
30 import android.view.accessibility.AccessibilityEvent;
31 import android.view.animation.Animation;
32 import android.view.animation.AnimationUtils;
33 import android.widget.AbsListView;
34 import android.widget.ImageView;
35 import android.widget.TextView;
36 
37 import com.android.contacts.R;
38 import com.android.contacts.activities.ActionBarAdapter;
39 import com.android.contacts.group.GroupMembersFragment;
40 import com.android.contacts.list.MultiSelectEntryContactListAdapter.SelectedContactsListener;
41 import com.android.contacts.logging.ListEvent.ActionType;
42 import com.android.contacts.logging.Logger;
43 import com.android.contacts.logging.SearchState;
44 import com.android.contacts.model.AccountTypeManager;
45 import com.android.contacts.model.account.AccountType;
46 import com.android.contacts.model.account.AccountWithDataSet;
47 import com.android.contacts.model.account.GoogleAccountType;
48 
49 import java.util.ArrayList;
50 import java.util.HashMap;
51 import java.util.List;
52 import java.util.Locale;
53 import java.util.Map;
54 import java.util.TreeSet;
55 
56 /**
57  * Fragment containing a contact list used for browsing contacts and optionally selecting
58  * multiple contacts via checkboxes.
59  */
60 public abstract class MultiSelectContactsListFragment<T extends MultiSelectEntryContactListAdapter>
61         extends ContactEntryListFragment<T>
62         implements SelectedContactsListener {
63 
64     protected boolean mAnimateOnLoad;
65     private static final String TAG = "MultiContactsList";
66 
67     public interface OnCheckBoxListActionListener {
onStartDisplayingCheckBoxes()68         void onStartDisplayingCheckBoxes();
onSelectedContactIdsChanged()69         void onSelectedContactIdsChanged();
onStopDisplayingCheckBoxes()70         void onStopDisplayingCheckBoxes();
71     }
72 
73     private static final String EXTRA_KEY_SELECTED_CONTACTS = "selected_contacts";
74 
75     private OnCheckBoxListActionListener mCheckBoxListListener;
76 
setCheckBoxListListener(OnCheckBoxListActionListener checkBoxListListener)77     public void setCheckBoxListListener(OnCheckBoxListActionListener checkBoxListListener) {
78         mCheckBoxListListener = checkBoxListListener;
79     }
80 
setAnimateOnLoad(boolean shouldAnimate)81     public void setAnimateOnLoad(boolean shouldAnimate) {
82         mAnimateOnLoad = shouldAnimate;
83     }
84 
85     @Override
onSelectedContactsChanged()86     public void onSelectedContactsChanged() {
87         if (mCheckBoxListListener != null) mCheckBoxListListener.onSelectedContactIdsChanged();
88     }
89 
90     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)91     public View onCreateView(LayoutInflater inflater, ViewGroup container,
92             Bundle savedInstanceState) {
93         super.onCreateView(inflater, container, savedInstanceState);
94         if (savedInstanceState == null && mAnimateOnLoad) {
95             setLayoutAnimation(getListView(), R.anim.slide_and_fade_in_layout_animation);
96         }
97         return getView();
98     }
99 
100     @Override
onActivityCreated(Bundle savedInstanceState)101     public void onActivityCreated(Bundle savedInstanceState) {
102         super.onActivityCreated(savedInstanceState);
103         if (savedInstanceState != null) {
104             final TreeSet<Long> selectedContactIds = (TreeSet<Long>)
105                     savedInstanceState.getSerializable(EXTRA_KEY_SELECTED_CONTACTS);
106             getAdapter().setSelectedContactIds(selectedContactIds);
107         }
108     }
109 
110     @Override
onStart()111     public void onStart() {
112         super.onStart();
113         if (mCheckBoxListListener != null) {
114             mCheckBoxListListener.onSelectedContactIdsChanged();
115         }
116     }
117 
getSelectedContactIds()118     public TreeSet<Long> getSelectedContactIds() {
119         return getAdapter().getSelectedContactIds();
120     }
121 
getSelectedContactIdsArray()122     public long[] getSelectedContactIdsArray() {
123         return getAdapter().getSelectedContactIdsArray();
124     }
125 
126     @Override
configureAdapter()127     protected void configureAdapter() {
128         super.configureAdapter();
129         getAdapter().setSelectedContactsListener(this);
130     }
131 
132     @Override
onSaveInstanceState(Bundle outState)133     public void onSaveInstanceState(Bundle outState) {
134         super.onSaveInstanceState(outState);
135         outState.putSerializable(EXTRA_KEY_SELECTED_CONTACTS, getSelectedContactIds());
136     }
137 
displayCheckBoxes(boolean displayCheckBoxes)138     public void displayCheckBoxes(boolean displayCheckBoxes) {
139         if (getAdapter() != null) {
140             getAdapter().setDisplayCheckBoxes(displayCheckBoxes);
141             if (!displayCheckBoxes) {
142                 clearCheckBoxes();
143             }
144         }
145     }
146 
clearCheckBoxes()147     public void clearCheckBoxes() {
148         getAdapter().setSelectedContactIds(new TreeSet<Long>());
149     }
150 
151     @Override
onItemLongClick(int position, long id)152     protected boolean onItemLongClick(int position, long id) {
153         final int previouslySelectedCount = getAdapter().getSelectedContactIds().size();
154         final long contactId = getContactId(position);
155         final int partition = getAdapter().getPartitionForPosition(position);
156         if (contactId >= 0 && partition == ContactsContract.Directory.DEFAULT) {
157             if (mCheckBoxListListener != null) {
158                 mCheckBoxListListener.onStartDisplayingCheckBoxes();
159             }
160             getAdapter().toggleSelectionOfContactId(contactId);
161             Logger.logListEvent(ActionType.SELECT, getListType(),
162                     /* count */ getAdapter().getCount(), /* clickedIndex */ position,
163                     /* numSelected */ 1);
164             // Manually send clicked event if there is a checkbox.
165             // See b/24098561. TalkBack will not read it otherwise.
166             final int index = position + getListView().getHeaderViewsCount() - getListView()
167                     .getFirstVisiblePosition();
168             if (index >= 0 && index < getListView().getChildCount()) {
169                 getListView().getChildAt(index).sendAccessibilityEvent(AccessibilityEvent
170                         .TYPE_VIEW_CLICKED);
171             }
172         }
173         final int nowSelectedCount = getAdapter().getSelectedContactIds().size();
174         if (mCheckBoxListListener != null
175                 && previouslySelectedCount != 0 && nowSelectedCount == 0) {
176             // Last checkbox has been unchecked. So we should stop displaying checkboxes.
177             mCheckBoxListListener.onStopDisplayingCheckBoxes();
178         }
179         return true;
180     }
181 
182     @Override
onItemClick(int position, long id)183     protected void onItemClick(int position, long id) {
184         final long contactId = getContactId(position);
185         if (contactId < 0) {
186             return;
187         }
188         if (getAdapter().isDisplayingCheckBoxes()) {
189             getAdapter().toggleSelectionOfContactId(contactId);
190         }
191         if (mCheckBoxListListener != null && getAdapter().getSelectedContactIds().size() == 0) {
192             mCheckBoxListListener.onStopDisplayingCheckBoxes();
193         }
194     }
195 
getContactId(int position)196     private long getContactId(int position) {
197         final int contactIdColumnIndex = getAdapter().getContactColumnIdIndex();
198 
199         final Cursor cursor = (Cursor) getAdapter().getItem(position);
200         if (cursor != null) {
201             if (cursor.getColumnCount() > contactIdColumnIndex) {
202                 return cursor.getLong(contactIdColumnIndex);
203             }
204         }
205 
206         Log.w(TAG, "Failed to get contact ID from cursor column " + contactIdColumnIndex);
207         return -1;
208     }
209 
210     /**
211      * Returns the state of the search results currently presented to the user.
212      */
createSearchState()213     public SearchState createSearchState() {
214         return createSearchState(/* selectedPosition */ -1);
215     }
216 
217     /**
218      * Returns the state of the search results presented to the user
219      * at the time the result in the given position was clicked.
220      */
createSearchStateForSearchResultClick(int selectedPosition)221     public SearchState createSearchStateForSearchResultClick(int selectedPosition) {
222         return createSearchState(selectedPosition);
223     }
224 
createSearchState(int selectedPosition)225     private SearchState createSearchState(int selectedPosition) {
226         final MultiSelectEntryContactListAdapter adapter = getAdapter();
227         if (adapter == null) {
228             return null;
229         }
230         final SearchState searchState = new SearchState();
231         searchState.queryLength = adapter.getQueryString() == null
232                 ? 0 : adapter.getQueryString().length();
233         searchState.numPartitions = adapter.getPartitionCount();
234 
235         // Set the number of results displayed to the user.  Note that the adapter.getCount(),
236         // value does not always match the number of results actually displayed to the user,
237         // which is why we calculate it manually.
238         final List<Integer> numResultsInEachPartition = new ArrayList<>();
239         for (int i = 0; i < adapter.getPartitionCount(); i++) {
240             final Cursor cursor = adapter.getCursor(i);
241             if (cursor == null || cursor.isClosed()) {
242                 // Something went wrong, abort.
243                 numResultsInEachPartition.clear();
244                 break;
245             }
246             numResultsInEachPartition.add(cursor.getCount());
247         }
248         if (!numResultsInEachPartition.isEmpty()) {
249             int numResults = 0;
250             for (int i = 0; i < numResultsInEachPartition.size(); i++) {
251                 numResults += numResultsInEachPartition.get(i);
252             }
253             searchState.numResults = numResults;
254         }
255 
256         // If a selection was made, set additional search state
257         if (selectedPosition >= 0) {
258             searchState.selectedPartition = adapter.getPartitionForPosition(selectedPosition);
259             searchState.selectedIndexInPartition = adapter.getOffsetInPartition(selectedPosition);
260             final Cursor cursor = adapter.getCursor(searchState.selectedPartition);
261             searchState.numResultsInSelectedPartition =
262                     cursor == null || cursor.isClosed() ? -1 : cursor.getCount();
263 
264             // Calculate the index across all partitions
265             if (!numResultsInEachPartition.isEmpty()) {
266                 int selectedIndex = 0;
267                 for (int i = 0; i < searchState.selectedPartition; i++) {
268                     selectedIndex += numResultsInEachPartition.get(i);
269                 }
270                 selectedIndex += searchState.selectedIndexInPartition;
271                 searchState.selectedIndex = selectedIndex;
272             }
273         }
274         return searchState;
275     }
276 
setLayoutAnimation(final ViewGroup view, int animationId)277     protected void setLayoutAnimation(final ViewGroup view, int animationId) {
278         if (view == null) {
279             return;
280         }
281         view.setLayoutAnimationListener(new Animation.AnimationListener() {
282             @Override
283             public void onAnimationStart(Animation animation) {
284             }
285 
286             @Override
287             public void onAnimationEnd(Animation animation) {
288                 view.setLayoutAnimation(null);
289             }
290 
291             @Override
292             public void onAnimationRepeat(Animation animation) {
293             }
294         });
295         view.setLayoutAnimation(AnimationUtils.loadLayoutAnimation(getActivity(), animationId));
296     }
297 
298     @Override
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)299     public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
300             int totalItemCount) {
301         final View accountFilterContainer = getView().findViewById(
302                 R.id.account_filter_header_container);
303         if (accountFilterContainer == null) {
304             return;
305         }
306 
307         int firstCompletelyVisibleItem = firstVisibleItem;
308         if (view != null && view.getChildAt(0) != null && view.getChildAt(0).getTop() < 0) {
309             firstCompletelyVisibleItem++;
310         }
311 
312         if (firstCompletelyVisibleItem == 0) {
313             ViewCompat.setElevation(accountFilterContainer, 0);
314         } else {
315             ViewCompat.setElevation(accountFilterContainer,
316                     getResources().getDimension(R.dimen.contact_list_header_elevation));
317         }
318     }
319 
bindListHeaderCustom(View listView, View accountFilterContainer)320     protected void bindListHeaderCustom(View listView, View accountFilterContainer) {
321         bindListHeaderCommon(listView, accountFilterContainer);
322 
323         final TextView accountFilterHeader = (TextView) accountFilterContainer.findViewById(
324                 R.id.account_filter_header);
325         accountFilterHeader.setText(R.string.listCustomView);
326         accountFilterHeader.setAllCaps(false);
327 
328         final ImageView accountFilterHeaderIcon = (ImageView) accountFilterContainer
329                 .findViewById(R.id.account_filter_icon);
330         accountFilterHeaderIcon.setVisibility(View.GONE);
331     }
332 
333     /**
334      * Show account icon, count of contacts and account name in the header of the list.
335      */
bindListHeader(Context context, View listView, View accountFilterContainer, AccountWithDataSet accountWithDataSet, int memberCount)336     protected void bindListHeader(Context context, View listView, View accountFilterContainer,
337             AccountWithDataSet accountWithDataSet, int memberCount) {
338         if (memberCount < 0) {
339             hideHeaderAndAddPadding(context, listView, accountFilterContainer);
340             return;
341         }
342 
343         bindListHeaderCommon(listView, accountFilterContainer);
344 
345         final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(context);
346         final AccountType accountType = accountTypeManager.getAccountType(
347                 accountWithDataSet.type, accountWithDataSet.dataSet);
348 
349         // Set text of count of contacts and account name
350         final TextView accountFilterHeader = (TextView) accountFilterContainer.findViewById(
351                 R.id.account_filter_header);
352         String headerText;
353         Map<String, Object> arguments = new HashMap<>();
354         arguments.put("count", memberCount);
355         if (shouldShowAccountName(accountType)) {
356             arguments.put("account", accountWithDataSet.name);
357             MessageFormat msgFormat = new MessageFormat(
358                 getResources().getString(R.string.contacts_count_with_account),
359                 Locale.getDefault());
360             headerText = msgFormat.format(arguments);
361         } else {
362             MessageFormat msgFormat = new MessageFormat(
363                 getResources().getString(R.string.contacts_count),
364                 Locale.getDefault());
365             headerText = msgFormat.format(arguments);
366         }
367         accountFilterHeader.setText(headerText);
368         accountFilterHeader.setAllCaps(false);
369 
370         // Set icon of the account
371         final Drawable icon = accountType != null ? accountType.getDisplayIcon(context) : null;
372         final ImageView accountFilterHeaderIcon = (ImageView) accountFilterContainer
373                 .findViewById(R.id.account_filter_icon);
374 
375         // If it's a writable Google account, we set icon size as 24dp; otherwise, we set it as
376         // 20dp. And we need to change margin accordingly. This is because the Google icon looks
377         // smaller when the icons are of the same size.
378         if (accountType instanceof GoogleAccountType) {
379             accountFilterHeaderIcon.getLayoutParams().height = getResources()
380                     .getDimensionPixelOffset(R.dimen.contact_browser_list_header_icon_size);
381             accountFilterHeaderIcon.getLayoutParams().width = getResources()
382                     .getDimensionPixelOffset(R.dimen.contact_browser_list_header_icon_size);
383 
384             setMargins(accountFilterHeaderIcon,
385                     getResources().getDimensionPixelOffset(
386                             R.dimen.contact_browser_list_header_icon_left_margin),
387                     getResources().getDimensionPixelOffset(
388                             R.dimen.contact_browser_list_header_icon_right_margin));
389         } else {
390             accountFilterHeaderIcon.getLayoutParams().height = getResources()
391                     .getDimensionPixelOffset(R.dimen.contact_browser_list_header_icon_size_alt);
392             accountFilterHeaderIcon.getLayoutParams().width = getResources()
393                     .getDimensionPixelOffset(R.dimen.contact_browser_list_header_icon_size_alt);
394 
395             setMargins(accountFilterHeaderIcon,
396                     getResources().getDimensionPixelOffset(
397                             R.dimen.contact_browser_list_header_icon_left_margin_alt),
398                     getResources().getDimensionPixelOffset(
399                             R.dimen.contact_browser_list_header_icon_right_margin_alt));
400         }
401         accountFilterHeaderIcon.requestLayout();
402 
403         accountFilterHeaderIcon.setVisibility(View.VISIBLE);
404         accountFilterHeaderIcon.setImageDrawable(icon);
405     }
406 
shouldShowAccountName(AccountType accountType)407     private boolean shouldShowAccountName(AccountType accountType) {
408         return (accountType.isGroupMembershipEditable() && this instanceof GroupMembersFragment)
409                 || GoogleAccountType.ACCOUNT_TYPE.equals(accountType.accountType);
410     }
411 
setMargins(View v, int l, int r)412     private void setMargins(View v, int l, int r) {
413         if (v.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) {
414             ViewGroup.MarginLayoutParams p = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
415             p.setMarginStart(l);
416             p.setMarginEnd(r);
417             v.setLayoutParams(p);
418             v.requestLayout();
419         }
420     }
421 
bindListHeaderCommon(View listView, View accountFilterContainer)422     private void bindListHeaderCommon(View listView, View accountFilterContainer) {
423         // Show header and remove top padding of the list
424         accountFilterContainer.setVisibility(View.VISIBLE);
425         setListViewPaddingTop(listView, /* paddingTop */ 0);
426     }
427 
428     /**
429      * Hide header of list view and add padding to the top of list view.
430      */
hideHeaderAndAddPadding(Context context, View listView, View accountFilterContainer)431     protected void hideHeaderAndAddPadding(Context context, View listView,
432             View accountFilterContainer) {
433         accountFilterContainer.setVisibility(View.GONE);
434         setListViewPaddingTop(listView,
435                 /* paddingTop */ context.getResources().getDimensionPixelSize(
436                         R.dimen.contact_browser_list_item_padding_top_or_bottom));
437     }
438 
setListViewPaddingTop(View listView, int paddingTop)439     private void setListViewPaddingTop(View listView, int paddingTop) {
440         listView.setPadding(listView.getPaddingLeft(), paddingTop, listView.getPaddingRight(),
441                 listView.getPaddingBottom());
442     }
443 
444     /**
445      * Returns the {@link ActionBarAdapter} object associated with list fragment.
446      */
getActionBarAdapter()447     public ActionBarAdapter getActionBarAdapter() {
448         return null;
449     }
450 }
451