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