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