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