1 /*
2  * Copyright (C) 2013 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.dialer.list;
17 
18 import static android.Manifest.permission.READ_CONTACTS;
19 
20 import android.animation.Animator;
21 import android.animation.AnimatorSet;
22 import android.animation.ObjectAnimator;
23 import android.app.Activity;
24 import android.app.Fragment;
25 import android.app.LoaderManager;
26 import android.content.CursorLoader;
27 import android.content.Loader;
28 import android.content.pm.PackageManager;
29 import android.database.Cursor;
30 import android.graphics.Rect;
31 import android.net.Uri;
32 import android.os.Bundle;
33 import android.os.Trace;
34 import android.support.v13.app.FragmentCompat;
35 import android.util.Log;
36 import android.view.LayoutInflater;
37 import android.view.View;
38 import android.view.ViewGroup;
39 import android.view.ViewTreeObserver;
40 import android.view.animation.AnimationUtils;
41 import android.view.animation.LayoutAnimationController;
42 import android.widget.AbsListView;
43 import android.widget.AdapterView;
44 import android.widget.AdapterView.OnItemClickListener;
45 import android.widget.FrameLayout;
46 import android.widget.FrameLayout.LayoutParams;
47 import android.widget.ImageView;
48 import android.widget.ListView;
49 
50 import com.android.contacts.common.ContactPhotoManager;
51 import com.android.contacts.common.ContactTileLoaderFactory;
52 import com.android.contacts.common.list.ContactTileView;
53 import com.android.contacts.common.list.OnPhoneNumberPickerActionListener;
54 import com.android.contacts.common.util.PermissionsUtil;
55 import com.android.dialer.R;
56 import com.android.dialer.widget.EmptyContentView;
57 import com.android.incallui.Call.LogState;
58 
59 import java.util.ArrayList;
60 import java.util.HashMap;
61 
62 /**
63  * This fragment displays the user's favorite/frequent contacts in a grid.
64  */
65 public class SpeedDialFragment extends Fragment implements OnItemClickListener,
66         PhoneFavoritesTileAdapter.OnDataSetChangedForAnimationListener,
67         EmptyContentView.OnEmptyViewActionButtonClickedListener,
68         FragmentCompat.OnRequestPermissionsResultCallback {
69 
70     private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1;
71 
72     /**
73      * By default, the animation code assumes that all items in a list view are of the same height
74      * when animating new list items into view (e.g. from the bottom of the screen into view).
75      * This can cause incorrect translation offsets when a item that is larger or smaller than
76      * other list item is removed from the list. This key is used to provide the actual height
77      * of the removed object so that the actual translation appears correct to the user.
78      */
79     private static final long KEY_REMOVED_ITEM_HEIGHT = Long.MAX_VALUE;
80 
81     private static final String TAG = "SpeedDialFragment";
82     private static final boolean DEBUG = false;
83 
84     private int mAnimationDuration;
85 
86     /**
87      * Used with LoaderManager.
88      */
89     private static int LOADER_ID_CONTACT_TILE = 1;
90 
91     public interface HostInterface {
setDragDropController(DragDropController controller)92         public void setDragDropController(DragDropController controller);
showAllContactsTab()93         public void showAllContactsTab();
94     }
95 
96     private class ContactTileLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> {
97         @Override
onCreateLoader(int id, Bundle args)98         public CursorLoader onCreateLoader(int id, Bundle args) {
99             if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onCreateLoader.");
100             return ContactTileLoaderFactory.createStrequentPhoneOnlyLoader(getActivity());
101         }
102 
103         @Override
onLoadFinished(Loader<Cursor> loader, Cursor data)104         public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
105             if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoadFinished");
106             mContactTileAdapter.setContactCursor(data);
107             setEmptyViewVisibility(mContactTileAdapter.getCount() == 0);
108         }
109 
110         @Override
onLoaderReset(Loader<Cursor> loader)111         public void onLoaderReset(Loader<Cursor> loader) {
112             if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoaderReset. ");
113         }
114     }
115 
116     private class ContactTileAdapterListener implements ContactTileView.Listener {
117         @Override
onContactSelected(Uri contactUri, Rect targetRect)118         public void onContactSelected(Uri contactUri, Rect targetRect) {
119             if (mPhoneNumberPickerActionListener != null) {
120                 mPhoneNumberPickerActionListener.onPickDataUri(contactUri,
121                         false /* isVideoCall */, LogState.INITIATION_SPEED_DIAL);
122             }
123         }
124 
125         @Override
onCallNumberDirectly(String phoneNumber)126         public void onCallNumberDirectly(String phoneNumber) {
127             if (mPhoneNumberPickerActionListener != null) {
128                 mPhoneNumberPickerActionListener.onPickPhoneNumber(phoneNumber,
129                         false /* isVideoCall */, LogState.INITIATION_SPEED_DIAL);
130             }
131         }
132 
133         @Override
getApproximateTileWidth()134         public int getApproximateTileWidth() {
135             return getView().getWidth();
136         }
137     }
138 
139     private class ScrollListener implements ListView.OnScrollListener {
140         @Override
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)141         public void onScroll(AbsListView view,
142                 int firstVisibleItem, int visibleItemCount, int totalItemCount) {
143             if (mActivityScrollListener != null) {
144                 mActivityScrollListener.onListFragmentScroll(firstVisibleItem, visibleItemCount,
145                     totalItemCount);
146             }
147         }
148 
149         @Override
onScrollStateChanged(AbsListView view, int scrollState)150         public void onScrollStateChanged(AbsListView view, int scrollState) {
151             mActivityScrollListener.onListFragmentScrollStateChange(scrollState);
152         }
153     }
154 
155     private OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener;
156 
157     private OnListFragmentScrolledListener mActivityScrollListener;
158     private PhoneFavoritesTileAdapter mContactTileAdapter;
159 
160     private View mParentView;
161 
162     private PhoneFavoriteListView mListView;
163 
164     private View mContactTileFrame;
165 
166     private final HashMap<Long, Integer> mItemIdTopMap = new HashMap<Long, Integer>();
167     private final HashMap<Long, Integer> mItemIdLeftMap = new HashMap<Long, Integer>();
168 
169     /**
170      * Layout used when there are no favorites.
171      */
172     private EmptyContentView mEmptyView;
173 
174     private final ContactTileView.Listener mContactTileAdapterListener =
175             new ContactTileAdapterListener();
176     private final LoaderManager.LoaderCallbacks<Cursor> mContactTileLoaderListener =
177             new ContactTileLoaderListener();
178     private final ScrollListener mScrollListener = new ScrollListener();
179 
180     @Override
onAttach(Activity activity)181     public void onAttach(Activity activity) {
182         if (DEBUG) Log.d(TAG, "onAttach()");
183         super.onAttach(activity);
184 
185         // Construct two base adapters which will become part of PhoneFavoriteMergedAdapter.
186         // We don't construct the resultant adapter at this moment since it requires LayoutInflater
187         // that will be available on onCreateView().
188         mContactTileAdapter = new PhoneFavoritesTileAdapter(activity, mContactTileAdapterListener,
189                 this);
190         mContactTileAdapter.setPhotoLoader(ContactPhotoManager.getInstance(activity));
191     }
192 
193     @Override
onCreate(Bundle savedState)194     public void onCreate(Bundle savedState) {
195         if (DEBUG) Log.d(TAG, "onCreate()");
196         Trace.beginSection(TAG + " onCreate");
197         super.onCreate(savedState);
198 
199         mAnimationDuration = getResources().getInteger(R.integer.fade_duration);
200         Trace.endSection();
201     }
202 
203     @Override
onResume()204     public void onResume() {
205         Trace.beginSection(TAG + " onResume");
206         super.onResume();
207         if (mContactTileAdapter != null) {
208             mContactTileAdapter.refreshContactsPreferences();
209         }
210         if (PermissionsUtil.hasContactsPermissions(getActivity())) {
211             if (getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE) == null) {
212                 getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null,
213                         mContactTileLoaderListener);
214 
215             } else {
216                 getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE).forceLoad();
217             }
218 
219             mEmptyView.setDescription(R.string.speed_dial_empty);
220             mEmptyView.setActionLabel(R.string.speed_dial_empty_add_favorite_action);
221         } else {
222             mEmptyView.setDescription(R.string.permission_no_speeddial);
223             mEmptyView.setActionLabel(R.string.permission_single_turn_on);
224         }
225         Trace.endSection();
226     }
227 
228     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)229     public View onCreateView(LayoutInflater inflater, ViewGroup container,
230             Bundle savedInstanceState) {
231         Trace.beginSection(TAG + " onCreateView");
232         mParentView = inflater.inflate(R.layout.speed_dial_fragment, container, false);
233 
234         mListView = (PhoneFavoriteListView) mParentView.findViewById(R.id.contact_tile_list);
235         mListView.setOnItemClickListener(this);
236         mListView.setVerticalScrollBarEnabled(false);
237         mListView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT);
238         mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY);
239         mListView.getDragDropController().addOnDragDropListener(mContactTileAdapter);
240 
241         final ImageView dragShadowOverlay =
242                 (ImageView) getActivity().findViewById(R.id.contact_tile_drag_shadow_overlay);
243         mListView.setDragShadowOverlay(dragShadowOverlay);
244 
245         mEmptyView = (EmptyContentView) mParentView.findViewById(R.id.empty_list_view);
246         mEmptyView.setImage(R.drawable.empty_speed_dial);
247         mEmptyView.setActionClickedListener(this);
248 
249         mContactTileFrame = mParentView.findViewById(R.id.contact_tile_frame);
250 
251         final LayoutAnimationController controller = new LayoutAnimationController(
252                 AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in));
253         controller.setDelay(0);
254         mListView.setLayoutAnimation(controller);
255         mListView.setAdapter(mContactTileAdapter);
256 
257         mListView.setOnScrollListener(mScrollListener);
258         mListView.setFastScrollEnabled(false);
259         mListView.setFastScrollAlwaysVisible(false);
260 
261         //prevent content changes of the list from firing accessibility events.
262         mListView.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_NONE);
263         ContentChangedFilter.addToParent(mListView);
264 
265         Trace.endSection();
266         return mParentView;
267     }
268 
hasFrequents()269     public boolean hasFrequents() {
270         if (mContactTileAdapter == null) return false;
271         return mContactTileAdapter.getNumFrequents() > 0;
272     }
273 
setEmptyViewVisibility(final boolean visible)274     /* package */ void setEmptyViewVisibility(final boolean visible) {
275         final int previousVisibility = mEmptyView.getVisibility();
276         final int emptyViewVisibility = visible ? View.VISIBLE : View.GONE;
277         final int listViewVisibility = visible ? View.GONE : View.VISIBLE;
278 
279         if (previousVisibility != emptyViewVisibility) {
280             final FrameLayout.LayoutParams params = (LayoutParams) mContactTileFrame
281                     .getLayoutParams();
282             params.height = visible ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT;
283             mContactTileFrame.setLayoutParams(params);
284             mEmptyView.setVisibility(emptyViewVisibility);
285             mListView.setVisibility(listViewVisibility);
286         }
287     }
288 
289     @Override
onStart()290     public void onStart() {
291         super.onStart();
292 
293         final Activity activity = getActivity();
294 
295         try {
296             mActivityScrollListener = (OnListFragmentScrolledListener) activity;
297         } catch (ClassCastException e) {
298             throw new ClassCastException(activity.toString()
299                     + " must implement OnListFragmentScrolledListener");
300         }
301 
302         try {
303             OnDragDropListener listener = (OnDragDropListener) activity;
304             mListView.getDragDropController().addOnDragDropListener(listener);
305             ((HostInterface) activity).setDragDropController(mListView.getDragDropController());
306         } catch (ClassCastException e) {
307             throw new ClassCastException(activity.toString()
308                     + " must implement OnDragDropListener and HostInterface");
309         }
310 
311         try {
312             mPhoneNumberPickerActionListener = (OnPhoneNumberPickerActionListener) activity;
313         } catch (ClassCastException e) {
314             throw new ClassCastException(activity.toString()
315                     + " must implement PhoneFavoritesFragment.listener");
316         }
317 
318         // Use initLoader() instead of restartLoader() to refraining unnecessary reload.
319         // This method call implicitly assures ContactTileLoaderListener's onLoadFinished() will
320         // be called, on which we'll check if "all" contacts should be reloaded again or not.
321         if (PermissionsUtil.hasContactsPermissions(activity)) {
322             getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener);
323         } else {
324             setEmptyViewVisibility(true);
325         }
326     }
327 
328     /**
329      * {@inheritDoc}
330      *
331      * This is only effective for elements provided by {@link #mContactTileAdapter}.
332      * {@link #mContactTileAdapter} has its own logic for click events.
333      */
334     @Override
onItemClick(AdapterView<?> parent, View view, int position, long id)335     public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
336         final int contactTileAdapterCount = mContactTileAdapter.getCount();
337         if (position <= contactTileAdapterCount) {
338             Log.e(TAG, "onItemClick() event for unexpected position. "
339                     + "The position " + position + " is before \"all\" section. Ignored.");
340         }
341     }
342 
343     /**
344      * Cache the current view offsets into memory. Once a relayout of views in the ListView
345      * has happened due to a dataset change, the cached offsets are used to create animations
346      * that slide views from their previous positions to their new ones, to give the appearance
347      * that the views are sliding into their new positions.
348      */
saveOffsets(int removedItemHeight)349     private void saveOffsets(int removedItemHeight) {
350         final int firstVisiblePosition = mListView.getFirstVisiblePosition();
351         if (DEBUG) {
352             Log.d(TAG, "Child count : " + mListView.getChildCount());
353         }
354         for (int i = 0; i < mListView.getChildCount(); i++) {
355             final View child = mListView.getChildAt(i);
356             final int position = firstVisiblePosition + i;
357             // Since we are getting the position from mListView and then querying
358             // mContactTileAdapter, its very possible that things are out of sync
359             // and we might index out of bounds.  Let's make sure that this doesn't happen.
360             if (!mContactTileAdapter.isIndexInBound(position)) {
361                 continue;
362             }
363             final long itemId = mContactTileAdapter.getItemId(position);
364             if (DEBUG) {
365                 Log.d(TAG, "Saving itemId: " + itemId + " for listview child " + i + " Top: "
366                         + child.getTop());
367             }
368             mItemIdTopMap.put(itemId, child.getTop());
369             mItemIdLeftMap.put(itemId, child.getLeft());
370         }
371         mItemIdTopMap.put(KEY_REMOVED_ITEM_HEIGHT, removedItemHeight);
372     }
373 
374     /*
375      * Performs animations for the gridView
376      */
animateGridView(final long... idsInPlace)377     private void animateGridView(final long... idsInPlace) {
378         if (mItemIdTopMap.isEmpty()) {
379             // Don't do animations if the database is being queried for the first time and
380             // the previous item offsets have not been cached, or the user hasn't done anything
381             // (dragging, swiping etc) that requires an animation.
382             return;
383         }
384 
385         final ViewTreeObserver observer = mListView.getViewTreeObserver();
386         observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
387             @SuppressWarnings("unchecked")
388             @Override
389             public boolean onPreDraw() {
390                 observer.removeOnPreDrawListener(this);
391                 final int firstVisiblePosition = mListView.getFirstVisiblePosition();
392                 final AnimatorSet animSet = new AnimatorSet();
393                 final ArrayList<Animator> animators = new ArrayList<Animator>();
394                 for (int i = 0; i < mListView.getChildCount(); i++) {
395                     final View child = mListView.getChildAt(i);
396                     int position = firstVisiblePosition + i;
397 
398                     // Since we are getting the position from mListView and then querying
399                     // mContactTileAdapter, its very possible that things are out of sync
400                     // and we might index out of bounds.  Let's make sure that this doesn't happen.
401                     if (!mContactTileAdapter.isIndexInBound(position)) {
402                         continue;
403                     }
404 
405                     final long itemId = mContactTileAdapter.getItemId(position);
406 
407                     if (containsId(idsInPlace, itemId)) {
408                         animators.add(ObjectAnimator.ofFloat(
409                                 child, "alpha", 0.0f, 1.0f));
410                         break;
411                     } else {
412                         Integer startTop = mItemIdTopMap.get(itemId);
413                         Integer startLeft = mItemIdLeftMap.get(itemId);
414                         final int top = child.getTop();
415                         final int left = child.getLeft();
416                         int deltaX = 0;
417                         int deltaY = 0;
418 
419                         if (startLeft != null) {
420                             if (startLeft != left) {
421                                 deltaX = startLeft - left;
422                                 animators.add(ObjectAnimator.ofFloat(
423                                         child, "translationX", deltaX, 0.0f));
424                             }
425                         }
426 
427                         if (startTop != null) {
428                             if (startTop != top) {
429                                 deltaY = startTop - top;
430                                 animators.add(ObjectAnimator.ofFloat(
431                                         child, "translationY", deltaY, 0.0f));
432                             }
433                         }
434 
435                         if (DEBUG) {
436                             Log.d(TAG, "Found itemId: " + itemId + " for listview child " + i +
437                                     " Top: " + top +
438                                     " Delta: " + deltaY);
439                         }
440                     }
441                 }
442 
443                 if (animators.size() > 0) {
444                     animSet.setDuration(mAnimationDuration).playTogether(animators);
445                     animSet.start();
446                 }
447 
448                 mItemIdTopMap.clear();
449                 mItemIdLeftMap.clear();
450                 return true;
451             }
452         });
453     }
454 
containsId(long[] ids, long target)455     private boolean containsId(long[] ids, long target) {
456         // Linear search on array is fine because this is typically only 0-1 elements long
457         for (int i = 0; i < ids.length; i++) {
458             if (ids[i] == target) {
459                 return true;
460             }
461         }
462         return false;
463     }
464 
465     @Override
onDataSetChangedForAnimation(long... idsInPlace)466     public void onDataSetChangedForAnimation(long... idsInPlace) {
467         animateGridView(idsInPlace);
468     }
469 
470     @Override
cacheOffsetsForDatasetChange()471     public void cacheOffsetsForDatasetChange() {
472         saveOffsets(0);
473     }
474 
getListView()475     public AbsListView getListView() {
476         return mListView;
477     }
478 
479     @Override
onEmptyViewActionButtonClicked()480     public void onEmptyViewActionButtonClicked() {
481         final Activity activity = getActivity();
482         if (activity == null) {
483             return;
484         }
485 
486         if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) {
487           FragmentCompat.requestPermissions(this, new String[] {READ_CONTACTS},
488               READ_CONTACTS_PERMISSION_REQUEST_CODE);
489         } else {
490             // Switch tabs
491             ((HostInterface) activity).showAllContactsTab();
492         }
493     }
494 
495     @Override
onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults)496     public void onRequestPermissionsResult(int requestCode, String[] permissions,
497             int[] grantResults) {
498         if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) {
499             if (grantResults.length == 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) {
500                 PermissionsUtil.notifyPermissionGranted(getActivity(), READ_CONTACTS);
501             }
502         }
503     }
504 }
505