1 /* 2 * Copyright (C) 2017 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.dialer.contactsfragment; 18 19 import static android.Manifest.permission.READ_CONTACTS; 20 21 import android.app.Fragment; 22 import android.app.LoaderManager.LoaderCallbacks; 23 import android.content.BroadcastReceiver; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.Loader; 27 import android.content.pm.PackageManager; 28 import android.database.Cursor; 29 import android.net.Uri; 30 import android.os.Bundle; 31 import android.support.annotation.IntDef; 32 import android.support.annotation.Nullable; 33 import android.support.v13.app.FragmentCompat; 34 import android.support.v7.widget.LinearLayoutManager; 35 import android.support.v7.widget.RecyclerView; 36 import android.support.v7.widget.RecyclerView.Recycler; 37 import android.support.v7.widget.RecyclerView.State; 38 import android.view.LayoutInflater; 39 import android.view.View; 40 import android.view.View.OnScrollChangeListener; 41 import android.view.ViewGroup; 42 import android.widget.ImageView; 43 import android.widget.TextView; 44 import com.android.contacts.common.preference.ContactsPreferences; 45 import com.android.contacts.common.preference.ContactsPreferences.ChangeListener; 46 import com.android.dialer.common.Assert; 47 import com.android.dialer.common.FragmentUtils; 48 import com.android.dialer.common.LogUtil; 49 import com.android.dialer.performancereport.PerformanceReport; 50 import com.android.dialer.util.DialerUtils; 51 import com.android.dialer.util.IntentUtil; 52 import com.android.dialer.util.PermissionsUtil; 53 import com.android.dialer.widget.EmptyContentView; 54 import com.android.dialer.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; 55 import java.lang.annotation.Retention; 56 import java.lang.annotation.RetentionPolicy; 57 import java.util.Arrays; 58 59 /** Fragment containing a list of all contacts. */ 60 public class ContactsFragment extends Fragment 61 implements LoaderCallbacks<Cursor>, 62 OnScrollChangeListener, 63 OnEmptyViewActionButtonClickedListener, 64 ChangeListener { 65 66 /** An enum for the different types of headers that be inserted at position 0 in the list. */ 67 @Retention(RetentionPolicy.SOURCE) 68 @IntDef({Header.NONE, Header.ADD_CONTACT}) 69 public @interface Header { 70 int NONE = 0; 71 /** Header that allows the user to add a new contact. */ 72 int ADD_CONTACT = 1; 73 } 74 75 public static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; 76 77 private static final String EXTRA_HEADER = "extra_header"; 78 private static final String EXTRA_HAS_PHONE_NUMBERS = "extra_has_phone_numbers"; 79 80 /** 81 * Listen to broadcast events about permissions in order to be notified if the READ_CONTACTS 82 * permission is granted via the UI in another fragment. 83 */ 84 private final BroadcastReceiver readContactsPermissionGrantedReceiver = 85 new BroadcastReceiver() { 86 @Override 87 public void onReceive(Context context, Intent intent) { 88 loadContacts(); 89 } 90 }; 91 92 private FastScroller fastScroller; 93 private TextView anchoredHeader; 94 private RecyclerView recyclerView; 95 private LinearLayoutManager manager; 96 private ContactsAdapter adapter; 97 private EmptyContentView emptyContentView; 98 99 private @Header int header; 100 101 private ContactsPreferences contactsPrefs; 102 private boolean hasPhoneNumbers; 103 private String query; 104 105 /** 106 * Used to get a configured instance of ContactsFragment. 107 * 108 * <p>Current example of this fragment are the contacts tab and in creating a new favorite 109 * contact. For example, the contacts tab we use: 110 * 111 * <ul> 112 * <li>{@link Header#ADD_CONTACT} to insert a header that allows users to add a contact 113 * <li>Open contact cards on click 114 * </ul> 115 * 116 * And for the add favorite contact screen we might use: 117 * 118 * <ul> 119 * <li>{@link Header#NONE} so that all rows are contacts (i.e. no header inserted) 120 * <li>Send a selected contact to the parent activity. 121 * </ul> 122 * 123 * @param header determines the type of header inserted at position 0 in the contacts list 124 */ newInstance(@eader int header)125 public static ContactsFragment newInstance(@Header int header) { 126 ContactsFragment fragment = new ContactsFragment(); 127 Bundle args = new Bundle(); 128 args.putInt(EXTRA_HEADER, header); 129 fragment.setArguments(args); 130 return fragment; 131 } 132 133 /** 134 * Returns {@link ContactsFragment} with a list of contacts such that: 135 * 136 * <ul> 137 * <li>Each contact has a phone number 138 * <li>Contacts are filterable via {@link #updateQuery(String)} 139 * <li>There is no list header (i.e. {@link Header#NONE} 140 * <li>Clicking on a contact notifies the parent activity via {@link 141 * OnContactSelectedListener#onContactSelected(ImageView, Uri, long)}. 142 * </ul> 143 */ newAddFavoritesInstance()144 public static ContactsFragment newAddFavoritesInstance() { 145 ContactsFragment fragment = new ContactsFragment(); 146 Bundle args = new Bundle(); 147 args.putInt(EXTRA_HEADER, Header.NONE); 148 args.putBoolean(EXTRA_HAS_PHONE_NUMBERS, true); 149 fragment.setArguments(args); 150 return fragment; 151 } 152 153 @SuppressWarnings("WrongConstant") 154 @Override onCreate(@ullable Bundle savedInstanceState)155 public void onCreate(@Nullable Bundle savedInstanceState) { 156 super.onCreate(savedInstanceState); 157 contactsPrefs = new ContactsPreferences(getContext()); 158 contactsPrefs.registerChangeListener(this); 159 header = getArguments().getInt(EXTRA_HEADER); 160 hasPhoneNumbers = getArguments().getBoolean(EXTRA_HAS_PHONE_NUMBERS); 161 if (savedInstanceState == null) { 162 // The onHiddenChanged callback does not get called the first time the fragment is 163 // attached, so call it ourselves here. 164 onHiddenChanged(false); 165 } 166 } 167 168 @Override onStart()169 public void onStart() { 170 super.onStart(); 171 PermissionsUtil.registerPermissionReceiver( 172 getActivity(), readContactsPermissionGrantedReceiver, READ_CONTACTS); 173 } 174 175 @Override onStop()176 public void onStop() { 177 PermissionsUtil.unregisterPermissionReceiver( 178 getActivity(), readContactsPermissionGrantedReceiver); 179 super.onStop(); 180 } 181 182 @Nullable 183 @Override onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)184 public View onCreateView( 185 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 186 View view = inflater.inflate(R.layout.fragment_contacts, container, false); 187 fastScroller = view.findViewById(R.id.fast_scroller); 188 anchoredHeader = view.findViewById(R.id.header); 189 recyclerView = view.findViewById(R.id.recycler_view); 190 adapter = 191 new ContactsAdapter( 192 getContext(), header, FragmentUtils.getParent(this, OnContactSelectedListener.class)); 193 recyclerView.setAdapter(adapter); 194 manager = 195 new LinearLayoutManager(getContext()) { 196 @Override 197 public void onLayoutChildren(Recycler recycler, State state) { 198 super.onLayoutChildren(recycler, state); 199 int itemsShown = findLastVisibleItemPosition() - findFirstVisibleItemPosition() + 1; 200 if (adapter.getItemCount() > itemsShown) { 201 fastScroller.setVisibility(View.VISIBLE); 202 recyclerView.setOnScrollChangeListener(ContactsFragment.this); 203 } else { 204 fastScroller.setVisibility(View.GONE); 205 } 206 } 207 }; 208 recyclerView.setLayoutManager(manager); 209 210 emptyContentView = view.findViewById(R.id.empty_list_view); 211 emptyContentView.setImage(R.drawable.empty_contacts); 212 emptyContentView.setActionClickedListener(this); 213 214 if (PermissionsUtil.hasContactsReadPermissions(getContext())) { 215 loadContacts(); 216 } else { 217 emptyContentView.setDescription(R.string.permission_no_contacts); 218 emptyContentView.setActionLabel(R.string.permission_single_turn_on); 219 emptyContentView.setVisibility(View.VISIBLE); 220 recyclerView.setVisibility(View.GONE); 221 } 222 223 return view; 224 } 225 226 @Override onChange()227 public void onChange() { 228 if (getActivity() != null 229 && isAdded() 230 && PermissionsUtil.hasContactsReadPermissions(getContext())) { 231 getLoaderManager().restartLoader(0, null, this); 232 } 233 } 234 235 /** @return a loader according to sort order and display order. */ 236 @Override onCreateLoader(int id, Bundle args)237 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 238 ContactsCursorLoader cursorLoader = new ContactsCursorLoader(getContext(), hasPhoneNumbers); 239 cursorLoader.setQuery(query); 240 return cursorLoader; 241 } 242 updateQuery(String query)243 public void updateQuery(String query) { 244 this.query = query; 245 getLoaderManager().restartLoader(0, null, this); 246 } 247 248 @Override onLoadFinished(Loader<Cursor> loader, Cursor cursor)249 public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { 250 LogUtil.enterBlock("ContactsFragment.onLoadFinished"); 251 if (cursor == null || cursor.getCount() == 0) { 252 emptyContentView.setDescription(R.string.all_contacts_empty); 253 emptyContentView.setActionLabel(R.string.all_contacts_empty_add_contact_action); 254 emptyContentView.setVisibility(View.VISIBLE); 255 recyclerView.setVisibility(View.GONE); 256 } else { 257 emptyContentView.setVisibility(View.GONE); 258 recyclerView.setVisibility(View.VISIBLE); 259 adapter.updateCursor(cursor); 260 261 PerformanceReport.logOnScrollStateChange(recyclerView); 262 fastScroller.setup(adapter, manager); 263 } 264 } 265 266 @Override onLoaderReset(Loader<Cursor> loader)267 public void onLoaderReset(Loader<Cursor> loader) { 268 recyclerView.setAdapter(null); 269 recyclerView.setOnScrollChangeListener(null); 270 adapter = null; 271 contactsPrefs.unregisterChangeListener(); 272 } 273 274 /* 275 * When our recycler view updates, we need to ensure that our row headers and anchored header 276 * are in the correct state. 277 * 278 * The general rule is, when the row headers are shown, our anchored header is hidden. When the 279 * recycler view is scrolling through a sublist that has more than one element, we want to show 280 * out anchored header, to create the illusion that our row header has been anchored. In all 281 * other situations, we want to hide the anchor because that means we are transitioning between 282 * two sublists. 283 */ 284 @Override onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY)285 public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) { 286 fastScroller.updateContainerAndScrollBarPosition(recyclerView); 287 int firstVisibleItem = manager.findFirstVisibleItemPosition(); 288 int firstCompletelyVisible = manager.findFirstCompletelyVisibleItemPosition(); 289 if (firstCompletelyVisible == RecyclerView.NO_POSITION) { 290 // No items are visible, so there are no headers to update. 291 return; 292 } 293 String anchoredHeaderString = adapter.getHeaderString(firstCompletelyVisible); 294 295 OnContactsListScrolledListener listener = 296 FragmentUtils.getParent(this, OnContactsListScrolledListener.class); 297 if (listener != null) { 298 listener.onContactsListScrolled( 299 recyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING 300 || fastScroller.isDragStarted()); 301 } 302 303 // If the user swipes to the top of the list very quickly, there is some strange behavior 304 // between this method updating headers and adapter#onBindViewHolder updating headers. 305 // To overcome this, we refresh the headers to ensure they are correct. 306 if (firstVisibleItem == firstCompletelyVisible && firstVisibleItem == 0) { 307 adapter.refreshHeaders(); 308 anchoredHeader.setVisibility(View.INVISIBLE); 309 } else if (firstVisibleItem != 0) { // skip the add contact row 310 if (adapter.getHeaderString(firstVisibleItem).equals(anchoredHeaderString)) { 311 anchoredHeader.setText(anchoredHeaderString); 312 anchoredHeader.setVisibility(View.VISIBLE); 313 getContactHolder(firstVisibleItem).getHeaderView().setVisibility(View.INVISIBLE); 314 getContactHolder(firstCompletelyVisible).getHeaderView().setVisibility(View.INVISIBLE); 315 } else { 316 anchoredHeader.setVisibility(View.INVISIBLE); 317 getContactHolder(firstVisibleItem).getHeaderView().setVisibility(View.VISIBLE); 318 getContactHolder(firstCompletelyVisible).getHeaderView().setVisibility(View.VISIBLE); 319 } 320 } 321 } 322 getContactHolder(int position)323 private ContactViewHolder getContactHolder(int position) { 324 return ((ContactViewHolder) recyclerView.findViewHolderForAdapterPosition(position)); 325 } 326 327 @Override onEmptyViewActionButtonClicked()328 public void onEmptyViewActionButtonClicked() { 329 if (emptyContentView.getActionLabel() == R.string.permission_single_turn_on) { 330 String[] deniedPermissions = 331 PermissionsUtil.getPermissionsCurrentlyDenied( 332 getContext(), PermissionsUtil.allContactsGroupPermissionsUsedInDialer); 333 if (deniedPermissions.length > 0) { 334 LogUtil.i( 335 "ContactsFragment.onEmptyViewActionButtonClicked", 336 "Requesting permissions: " + Arrays.toString(deniedPermissions)); 337 FragmentCompat.requestPermissions( 338 this, deniedPermissions, READ_CONTACTS_PERMISSION_REQUEST_CODE); 339 } 340 341 } else if (emptyContentView.getActionLabel() 342 == R.string.all_contacts_empty_add_contact_action) { 343 // Add new contact 344 DialerUtils.startActivityWithErrorToast( 345 getContext(), IntentUtil.getNewContactIntent(), R.string.add_contact_not_available); 346 } else { 347 throw Assert.createIllegalStateFailException("Invalid empty content view action label."); 348 } 349 } 350 351 @Override onRequestPermissionsResult( int requestCode, String[] permissions, int[] grantResults)352 public void onRequestPermissionsResult( 353 int requestCode, String[] permissions, int[] grantResults) { 354 if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) { 355 if (grantResults.length >= 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { 356 // Force a refresh of the data since we were missing the permission before this. 357 PermissionsUtil.notifyPermissionGranted(getContext(), permissions[0]); 358 } 359 } 360 } 361 362 @Override onHiddenChanged(boolean hidden)363 public void onHiddenChanged(boolean hidden) { 364 super.onHiddenChanged(hidden); 365 OnContactsFragmentHiddenChangedListener listener = 366 FragmentUtils.getParent(this, OnContactsFragmentHiddenChangedListener.class); 367 if (listener != null) { 368 listener.onContactsFragmentHiddenChanged(hidden); 369 } 370 } 371 loadContacts()372 private void loadContacts() { 373 getLoaderManager().initLoader(0, null, this); 374 recyclerView.setVisibility(View.VISIBLE); 375 emptyContentView.setVisibility(View.GONE); 376 } 377 378 /** Listener for contacts list scroll state. */ 379 public interface OnContactsListScrolledListener { onContactsListScrolled(boolean isDragging)380 void onContactsListScrolled(boolean isDragging); 381 } 382 383 /** Listener to notify parents when a contact is selected. */ 384 public interface OnContactSelectedListener { 385 386 /** Called when a contact is selected in {@link ContactsFragment}. */ onContactSelected(ImageView photo, Uri contactUri, long contactId)387 void onContactSelected(ImageView photo, Uri contactUri, long contactId); 388 } 389 390 /** Listener for contacts fragment hidden state */ 391 public interface OnContactsFragmentHiddenChangedListener { onContactsFragmentHiddenChanged(boolean hidden)392 void onContactsFragmentHiddenChanged(boolean hidden); 393 } 394 } 395