Home | History | Annotate | Download | only in list
      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 android.content.ContentProviderOperation;
     19 import android.content.ContentUris;
     20 import android.content.ContentValues;
     21 import android.content.Context;
     22 import android.content.OperationApplicationException;
     23 import android.content.res.Resources;
     24 import android.database.Cursor;
     25 import android.net.Uri;
     26 import android.os.RemoteException;
     27 import android.provider.ContactsContract;
     28 import android.provider.ContactsContract.CommonDataKinds.Phone;
     29 import android.provider.ContactsContract.Contacts;
     30 import android.provider.ContactsContract.PinnedPositions;
     31 import android.support.annotation.VisibleForTesting;
     32 import android.text.TextUtils;
     33 import android.util.LongSparseArray;
     34 import android.view.View;
     35 import android.view.ViewGroup;
     36 import android.widget.BaseAdapter;
     37 import com.android.contacts.common.ContactTileLoaderFactory;
     38 import com.android.contacts.common.list.ContactEntry;
     39 import com.android.contacts.common.list.ContactTileView;
     40 import com.android.contacts.common.preference.ContactsPreferences;
     41 import com.android.dialer.app.R;
     42 import com.android.dialer.common.LogUtil;
     43 import com.android.dialer.contactphoto.ContactPhotoManager;
     44 import com.android.dialer.duo.Duo;
     45 import com.android.dialer.duo.DuoComponent;
     46 import com.android.dialer.logging.InteractionEvent;
     47 import com.android.dialer.logging.Logger;
     48 import com.android.dialer.shortcuts.ShortcutRefresher;
     49 import com.android.dialer.strictmode.StrictModeUtils;
     50 import com.google.common.collect.ComparisonChain;
     51 import java.util.ArrayList;
     52 import java.util.Comparator;
     53 import java.util.LinkedList;
     54 import java.util.List;
     55 import java.util.PriorityQueue;
     56 
     57 /** Also allows for a configurable number of columns as well as a maximum row of tiled contacts. */
     58 public class PhoneFavoritesTileAdapter extends BaseAdapter implements OnDragDropListener {
     59 
     60   // Pinned positions start from 1, so there are a total of 20 maximum pinned contacts
     61   private static final int PIN_LIMIT = 21;
     62   private static final String TAG = PhoneFavoritesTileAdapter.class.getSimpleName();
     63   private static final boolean DEBUG = false;
     64   /**
     65    * The soft limit on how many contact tiles to show. NOTE This soft limit would not restrict the
     66    * number of starred contacts to show, rather 1. If the count of starred contacts is less than
     67    * this limit, show 20 tiles total. 2. If the count of starred contacts is more than or equal to
     68    * this limit, show all starred tiles and no frequents.
     69    */
     70   private static final int TILES_SOFT_LIMIT = 20;
     71   /** Contact data stored in cache. This is used to populate the associated view. */
     72   private ArrayList<ContactEntry> contactEntries = null;
     73 
     74   private int numFrequents;
     75   private int numStarred;
     76 
     77   private ContactTileView.Listener listener;
     78   private OnDataSetChangedForAnimationListener dataSetChangedListener;
     79   private Context context;
     80   private Resources resources;
     81   private ContactsPreferences contactsPreferences;
     82   private final Comparator<ContactEntry> contactEntryComparator =
     83       new Comparator<ContactEntry>() {
     84         @Override
     85         public int compare(ContactEntry lhs, ContactEntry rhs) {
     86           return ComparisonChain.start()
     87               .compare(lhs.pinned, rhs.pinned)
     88               .compare(getPreferredSortName(lhs), getPreferredSortName(rhs))
     89               .result();
     90         }
     91 
     92         private String getPreferredSortName(ContactEntry contactEntry) {
     93           if (contactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY
     94               || TextUtils.isEmpty(contactEntry.nameAlternative)) {
     95             return contactEntry.namePrimary;
     96           }
     97           return contactEntry.nameAlternative;
     98         }
     99       };
    100   /** Back up of the temporarily removed Contact during dragging. */
    101   private ContactEntry draggedEntry = null;
    102   /** Position of the temporarily removed contact in the cache. */
    103   private int draggedEntryIndex = -1;
    104   /** New position of the temporarily removed contact in the cache. */
    105   private int dropEntryIndex = -1;
    106   /** New position of the temporarily entered contact in the cache. */
    107   private int dragEnteredEntryIndex = -1;
    108 
    109   private boolean awaitingRemove = false;
    110   private boolean delayCursorUpdates = false;
    111   private ContactPhotoManager photoManager;
    112 
    113   /** Indicates whether a drag is in process. */
    114   private boolean inDragging = false;
    115 
    116   public PhoneFavoritesTileAdapter(
    117       Context context,
    118       ContactTileView.Listener listener,
    119       OnDataSetChangedForAnimationListener dataSetChangedListener) {
    120     this.dataSetChangedListener = dataSetChangedListener;
    121     this.listener = listener;
    122     this.context = context;
    123     resources = context.getResources();
    124     contactsPreferences = new ContactsPreferences(this.context);
    125     numFrequents = 0;
    126     contactEntries = new ArrayList<>();
    127   }
    128 
    129   void setPhotoLoader(ContactPhotoManager photoLoader) {
    130     photoManager = photoLoader;
    131   }
    132 
    133   /**
    134    * Indicates whether a drag is in process.
    135    *
    136    * @param inDragging Boolean variable indicating whether there is a drag in process.
    137    */
    138   private void setInDragging(boolean inDragging) {
    139     delayCursorUpdates = inDragging;
    140     this.inDragging = inDragging;
    141   }
    142 
    143   void refreshContactsPreferences() {
    144     contactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY);
    145     contactsPreferences.refreshValue(ContactsPreferences.SORT_ORDER_KEY);
    146   }
    147 
    148   /**
    149    * Gets the number of frequents from the passed in cursor.
    150    *
    151    * <p>This methods is needed so the GroupMemberTileAdapter can override this.
    152    *
    153    * @param cursor The cursor to get number of frequents from.
    154    */
    155   private void saveNumFrequentsFromCursor(Cursor cursor) {
    156     numFrequents = cursor.getCount() - numStarred;
    157   }
    158 
    159   /**
    160    * Creates {@link ContactTileView}s for each item in {@link Cursor}.
    161    *
    162    * <p>Else use {@link ContactTileLoaderFactory}
    163    */
    164   void setContactCursor(Cursor cursor) {
    165     if (!delayCursorUpdates && cursor != null && !cursor.isClosed()) {
    166       numStarred = getNumStarredContacts(cursor);
    167       if (awaitingRemove) {
    168         dataSetChangedListener.cacheOffsetsForDatasetChange();
    169       }
    170 
    171       saveNumFrequentsFromCursor(cursor);
    172       saveCursorToCache(cursor);
    173       // cause a refresh of any views that rely on this data
    174       notifyDataSetChanged();
    175       // about to start redraw
    176       dataSetChangedListener.onDataSetChangedForAnimation();
    177     }
    178   }
    179 
    180   /**
    181    * Saves the cursor data to the cache, to speed up UI changes.
    182    *
    183    * @param cursor Returned cursor from {@link ContactTileLoaderFactory} with data to populate the
    184    *     view.
    185    */
    186   private void saveCursorToCache(Cursor cursor) {
    187     contactEntries.clear();
    188 
    189     if (cursor == null) {
    190       return;
    191     }
    192 
    193     final LongSparseArray<Object> duplicates = new LongSparseArray<>(cursor.getCount());
    194 
    195     // Track the length of {@link #mContactEntries} and compare to {@link #TILES_SOFT_LIMIT}.
    196     int counter = 0;
    197 
    198     // Data for logging
    199     int starredContactsCount = 0;
    200     int pinnedContactsCount = 0;
    201     int multipleNumbersContactsCount = 0;
    202     int contactsWithPhotoCount = 0;
    203     int contactsWithNameCount = 0;
    204     int lightbringerReachableContactsCount = 0;
    205 
    206     // The cursor should not be closed since this is invoked from a CursorLoader.
    207     if (cursor.moveToFirst()) {
    208       int starredColumn = cursor.getColumnIndexOrThrow(Contacts.STARRED);
    209       int contactIdColumn = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID);
    210       int photoUriColumn = cursor.getColumnIndexOrThrow(Contacts.PHOTO_URI);
    211       int lookupKeyColumn = cursor.getColumnIndexOrThrow(Contacts.LOOKUP_KEY);
    212       int pinnedColumn = cursor.getColumnIndexOrThrow(Contacts.PINNED);
    213       int nameColumn = cursor.getColumnIndexOrThrow(Contacts.DISPLAY_NAME_PRIMARY);
    214       int nameAlternativeColumn = cursor.getColumnIndexOrThrow(Contacts.DISPLAY_NAME_ALTERNATIVE);
    215       int isDefaultNumberColumn = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY);
    216       int phoneTypeColumn = cursor.getColumnIndexOrThrow(Phone.TYPE);
    217       int phoneLabelColumn = cursor.getColumnIndexOrThrow(Phone.LABEL);
    218       int phoneNumberColumn = cursor.getColumnIndexOrThrow(Phone.NUMBER);
    219       do {
    220         final int starred = cursor.getInt(starredColumn);
    221         final long id;
    222 
    223         // We display a maximum of TILES_SOFT_LIMIT contacts, or the total number of starred
    224         // whichever is greater.
    225         if (starred < 1 && counter >= TILES_SOFT_LIMIT) {
    226           break;
    227         } else {
    228           id = cursor.getLong(contactIdColumn);
    229         }
    230 
    231         final ContactEntry existing = (ContactEntry) duplicates.get(id);
    232         if (existing != null) {
    233           // Check if the existing number is a default number. If not, clear the phone number
    234           // and label fields so that the disambiguation dialog will show up.
    235           if (!existing.isDefaultNumber) {
    236             existing.phoneLabel = null;
    237             existing.phoneNumber = null;
    238           }
    239           continue;
    240         }
    241 
    242         final String photoUri = cursor.getString(photoUriColumn);
    243         final String lookupKey = cursor.getString(lookupKeyColumn);
    244         final int pinned = cursor.getInt(pinnedColumn);
    245         final String name = cursor.getString(nameColumn);
    246         final String nameAlternative = cursor.getString(nameAlternativeColumn);
    247         final boolean isStarred = cursor.getInt(starredColumn) > 0;
    248         final boolean isDefaultNumber = cursor.getInt(isDefaultNumberColumn) > 0;
    249 
    250         final ContactEntry contact = new ContactEntry();
    251 
    252         contact.id = id;
    253         contact.namePrimary =
    254             (!TextUtils.isEmpty(name)) ? name : resources.getString(R.string.missing_name);
    255         contact.nameAlternative =
    256             (!TextUtils.isEmpty(nameAlternative))
    257                 ? nameAlternative
    258                 : resources.getString(R.string.missing_name);
    259         contact.nameDisplayOrder = contactsPreferences.getDisplayOrder();
    260         contact.photoUri = (photoUri != null ? Uri.parse(photoUri) : null);
    261         contact.lookupKey = lookupKey;
    262         contact.lookupUri =
    263             ContentUris.withAppendedId(
    264                 Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), id);
    265         contact.isFavorite = isStarred;
    266         contact.isDefaultNumber = isDefaultNumber;
    267 
    268         // Set phone number and label
    269         final int phoneNumberType = cursor.getInt(phoneTypeColumn);
    270         final String phoneNumberCustomLabel = cursor.getString(phoneLabelColumn);
    271         contact.phoneLabel =
    272             (String) Phone.getTypeLabel(resources, phoneNumberType, phoneNumberCustomLabel);
    273         contact.phoneNumber = cursor.getString(phoneNumberColumn);
    274 
    275         contact.pinned = pinned;
    276         contactEntries.add(contact);
    277 
    278         // Set counts for logging
    279         if (isStarred) {
    280           // mNumStarred might be larger than the number of visible starred contact,
    281           // since it includes invisible ones (starred contact with no phone number).
    282           starredContactsCount++;
    283         }
    284         if (pinned != PinnedPositions.UNPINNED) {
    285           pinnedContactsCount++;
    286         }
    287         if (!TextUtils.isEmpty(name)) {
    288           contactsWithNameCount++;
    289         }
    290         if (photoUri != null) {
    291           contactsWithPhotoCount++;
    292         }
    293 
    294         duplicates.put(id, contact);
    295 
    296         counter++;
    297       } while (cursor.moveToNext());
    298     }
    299 
    300     awaitingRemove = false;
    301 
    302     arrangeContactsByPinnedPosition(contactEntries);
    303 
    304     ShortcutRefresher.refresh(context, contactEntries);
    305     notifyDataSetChanged();
    306 
    307     Duo duo = DuoComponent.get(context).getDuo();
    308     for (ContactEntry contact : contactEntries) {
    309       if (contact.phoneNumber == null) {
    310         multipleNumbersContactsCount++;
    311       } else if (duo.isReachable(context, contact.phoneNumber)) {
    312         lightbringerReachableContactsCount++;
    313       }
    314     }
    315 
    316     Logger.get(context)
    317         .logSpeedDialContactComposition(
    318             counter,
    319             starredContactsCount,
    320             pinnedContactsCount,
    321             multipleNumbersContactsCount,
    322             contactsWithPhotoCount,
    323             contactsWithNameCount,
    324             lightbringerReachableContactsCount);
    325     // Logs for manual testing
    326     LogUtil.v("PhoneFavoritesTileAdapter.saveCursorToCache", "counter: %d", counter);
    327     LogUtil.v(
    328         "PhoneFavoritesTileAdapter.saveCursorToCache",
    329         "starredContactsCount: %d",
    330         starredContactsCount);
    331     LogUtil.v(
    332         "PhoneFavoritesTileAdapter.saveCursorToCache",
    333         "pinnedContactsCount: %d",
    334         pinnedContactsCount);
    335     LogUtil.v(
    336         "PhoneFavoritesTileAdapter.saveCursorToCache",
    337         "multipleNumbersContactsCount: %d",
    338         multipleNumbersContactsCount);
    339     LogUtil.v(
    340         "PhoneFavoritesTileAdapter.saveCursorToCache",
    341         "contactsWithPhotoCount: %d",
    342         contactsWithPhotoCount);
    343     LogUtil.v(
    344         "PhoneFavoritesTileAdapter.saveCursorToCache",
    345         "contactsWithNameCount: %d",
    346         contactsWithNameCount);
    347   }
    348 
    349   /** Iterates over the {@link Cursor} Returns position of the first NON Starred Contact */
    350   private int getNumStarredContacts(Cursor cursor) {
    351     if (cursor == null) {
    352       return 0;
    353     }
    354 
    355     if (cursor.moveToFirst()) {
    356       int starredColumn = cursor.getColumnIndex(Contacts.STARRED);
    357       do {
    358         if (cursor.getInt(starredColumn) == 0) {
    359           return cursor.getPosition();
    360         }
    361       } while (cursor.moveToNext());
    362     }
    363     // There are not NON Starred contacts in cursor
    364     // Set divider position to end
    365     return cursor.getCount();
    366   }
    367 
    368   /** Returns the number of frequents that will be displayed in the list. */
    369   int getNumFrequents() {
    370     return numFrequents;
    371   }
    372 
    373   @Override
    374   public int getCount() {
    375     if (contactEntries == null) {
    376       return 0;
    377     }
    378 
    379     return contactEntries.size();
    380   }
    381 
    382   /**
    383    * Returns an ArrayList of the {@link ContactEntry}s that are to appear on the row for the given
    384    * position.
    385    */
    386   @Override
    387   public ContactEntry getItem(int position) {
    388     return contactEntries.get(position);
    389   }
    390 
    391   /**
    392    * For the top row of tiled contacts, the item id is the position of the row of contacts. For
    393    * frequent contacts, the item id is the maximum number of rows of tiled contacts + the actual
    394    * contact id. Since contact ids are always greater than 0, this guarantees that all items within
    395    * this adapter will always have unique ids.
    396    */
    397   @Override
    398   public long getItemId(int position) {
    399     return getItem(position).id;
    400   }
    401 
    402   @Override
    403   public boolean hasStableIds() {
    404     return true;
    405   }
    406 
    407   @Override
    408   public boolean areAllItemsEnabled() {
    409     return true;
    410   }
    411 
    412   @Override
    413   public boolean isEnabled(int position) {
    414     return getCount() > 0;
    415   }
    416 
    417   @Override
    418   public void notifyDataSetChanged() {
    419     if (DEBUG) {
    420       LogUtil.v(TAG, "notifyDataSetChanged");
    421     }
    422     super.notifyDataSetChanged();
    423   }
    424 
    425   @Override
    426   public View getView(int position, View convertView, ViewGroup parent) {
    427     if (DEBUG) {
    428       LogUtil.v(TAG, "get view for " + position);
    429     }
    430 
    431     PhoneFavoriteTileView tileView = null;
    432 
    433     if (convertView instanceof PhoneFavoriteTileView) {
    434       tileView = (PhoneFavoriteTileView) convertView;
    435     }
    436 
    437     if (tileView == null) {
    438       tileView =
    439           (PhoneFavoriteTileView) View.inflate(context, R.layout.phone_favorite_tile_view, null);
    440     }
    441     tileView.setPhotoManager(photoManager);
    442     tileView.setListener(listener);
    443     tileView.loadFromContact(getItem(position));
    444     tileView.setPosition(position);
    445     return tileView;
    446   }
    447 
    448   @Override
    449   public int getViewTypeCount() {
    450     return ViewTypes.COUNT;
    451   }
    452 
    453   @Override
    454   public int getItemViewType(int position) {
    455     return ViewTypes.TILE;
    456   }
    457 
    458   /**
    459    * Temporarily removes a contact from the list for UI refresh. Stores data for this contact in the
    460    * back-up variable.
    461    *
    462    * @param index Position of the contact to be removed.
    463    */
    464   private void popContactEntry(int index) {
    465     if (isIndexInBound(index)) {
    466       draggedEntry = contactEntries.get(index);
    467       draggedEntryIndex = index;
    468       dragEnteredEntryIndex = index;
    469       markDropArea(dragEnteredEntryIndex);
    470     }
    471   }
    472 
    473   /**
    474    * @param itemIndex Position of the contact in {@link #contactEntries}.
    475    * @return True if the given index is valid for {@link #contactEntries}.
    476    */
    477   boolean isIndexInBound(int itemIndex) {
    478     return itemIndex >= 0 && itemIndex < contactEntries.size();
    479   }
    480 
    481   /**
    482    * Mark the tile as drop area by given the item index in {@link #contactEntries}.
    483    *
    484    * @param itemIndex Position of the contact in {@link #contactEntries}.
    485    */
    486   private void markDropArea(int itemIndex) {
    487     if (draggedEntry != null
    488         && isIndexInBound(dragEnteredEntryIndex)
    489         && isIndexInBound(itemIndex)) {
    490       dataSetChangedListener.cacheOffsetsForDatasetChange();
    491       // Remove the old placeholder item and place the new placeholder item.
    492       contactEntries.remove(dragEnteredEntryIndex);
    493       dragEnteredEntryIndex = itemIndex;
    494       contactEntries.add(dragEnteredEntryIndex, ContactEntry.BLANK_ENTRY);
    495       ContactEntry.BLANK_ENTRY.id = draggedEntry.id;
    496       dataSetChangedListener.onDataSetChangedForAnimation();
    497       notifyDataSetChanged();
    498     }
    499   }
    500 
    501   /** Drops the temporarily removed contact to the desired location in the list. */
    502   private void handleDrop() {
    503     boolean changed = false;
    504     if (draggedEntry != null) {
    505       if (isIndexInBound(dragEnteredEntryIndex) && dragEnteredEntryIndex != draggedEntryIndex) {
    506         // Don't add the ContactEntry here (to prevent a double animation from occuring).
    507         // When we receive a new cursor the list of contact entries will automatically be
    508         // populated with the dragged ContactEntry at the correct spot.
    509         dropEntryIndex = dragEnteredEntryIndex;
    510         contactEntries.set(dropEntryIndex, draggedEntry);
    511         dataSetChangedListener.cacheOffsetsForDatasetChange();
    512         changed = true;
    513       } else if (isIndexInBound(draggedEntryIndex)) {
    514         // If {@link #mDragEnteredEntryIndex} is invalid,
    515         // falls back to the original position of the contact.
    516         contactEntries.remove(dragEnteredEntryIndex);
    517         contactEntries.add(draggedEntryIndex, draggedEntry);
    518         dropEntryIndex = draggedEntryIndex;
    519         notifyDataSetChanged();
    520       }
    521 
    522       if (changed && dropEntryIndex < PIN_LIMIT) {
    523         ArrayList<ContentProviderOperation> operations =
    524             getReflowedPinningOperations(contactEntries, draggedEntryIndex, dropEntryIndex);
    525         StrictModeUtils.bypass(() -> updateDatabaseWithPinnedPositions(operations));
    526       }
    527       draggedEntry = null;
    528     }
    529   }
    530 
    531   private void updateDatabaseWithPinnedPositions(ArrayList<ContentProviderOperation> operations) {
    532     if (operations.isEmpty()) {
    533       // Nothing to update
    534       return;
    535     }
    536     try {
    537       context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations);
    538       Logger.get(context).logInteraction(InteractionEvent.Type.SPEED_DIAL_PIN_CONTACT);
    539     } catch (RemoteException | OperationApplicationException e) {
    540       LogUtil.e(TAG, "Exception thrown when pinning contacts", e);
    541     }
    542   }
    543 
    544   /**
    545    * Used when a contact is removed from speeddial. This will both unstar and set pinned position of
    546    * the contact to PinnedPosition.DEMOTED so that it doesn't show up anymore in the favorites list.
    547    */
    548   private void unstarAndUnpinContact(Uri contactUri) {
    549     final ContentValues values = new ContentValues(2);
    550     values.put(Contacts.STARRED, false);
    551     values.put(Contacts.PINNED, PinnedPositions.DEMOTED);
    552     StrictModeUtils.bypass(
    553         () -> context.getContentResolver().update(contactUri, values, null, null));
    554   }
    555 
    556   /**
    557    * Given a list of contacts that each have pinned positions, rearrange the list (destructive) such
    558    * that all pinned contacts are in their defined pinned positions, and unpinned contacts take the
    559    * spaces between those pinned contacts. Demoted contacts should not appear in the resulting list.
    560    *
    561    * <p>This method also updates the pinned positions of pinned contacts so that they are all unique
    562    * positive integers within range from 0 to toArrange.size() - 1. This is because when the contact
    563    * entries are read from the database, it is possible for them to have overlapping pin positions
    564    * due to sync or modifications by third party apps.
    565    */
    566   @VisibleForTesting
    567   private void arrangeContactsByPinnedPosition(ArrayList<ContactEntry> toArrange) {
    568     final PriorityQueue<ContactEntry> pinnedQueue =
    569         new PriorityQueue<>(PIN_LIMIT, contactEntryComparator);
    570 
    571     final List<ContactEntry> unpinnedContacts = new LinkedList<>();
    572 
    573     final int length = toArrange.size();
    574     for (int i = 0; i < length; i++) {
    575       final ContactEntry contact = toArrange.get(i);
    576       // Decide whether the contact is hidden(demoted), pinned, or unpinned
    577       if (contact.pinned > PIN_LIMIT || contact.pinned == PinnedPositions.UNPINNED) {
    578         unpinnedContacts.add(contact);
    579       } else if (contact.pinned > PinnedPositions.DEMOTED) {
    580         // Demoted or contacts with negative pinned positions are ignored.
    581         // Pinned contacts go into a priority queue where they are ranked by pinned
    582         // position. This is required because the contacts provider does not return
    583         // contacts ordered by pinned position.
    584         pinnedQueue.add(contact);
    585       }
    586     }
    587 
    588     final int maxToPin = Math.min(PIN_LIMIT, pinnedQueue.size() + unpinnedContacts.size());
    589 
    590     toArrange.clear();
    591     for (int i = 1; i < maxToPin + 1; i++) {
    592       if (!pinnedQueue.isEmpty() && pinnedQueue.peek().pinned <= i) {
    593         final ContactEntry toPin = pinnedQueue.poll();
    594         toPin.pinned = i;
    595         toArrange.add(toPin);
    596       } else if (!unpinnedContacts.isEmpty()) {
    597         toArrange.add(unpinnedContacts.remove(0));
    598       }
    599     }
    600 
    601     // If there are still contacts in pinnedContacts at this point, it means that the pinned
    602     // positions of these pinned contacts exceed the actual number of contacts in the list.
    603     // For example, the user had 10 frequents, starred and pinned one of them at the last spot,
    604     // and then cleared frequents. Contacts in this situation should become unpinned.
    605     while (!pinnedQueue.isEmpty()) {
    606       final ContactEntry entry = pinnedQueue.poll();
    607       entry.pinned = PinnedPositions.UNPINNED;
    608       toArrange.add(entry);
    609     }
    610 
    611     // Any remaining unpinned contacts that weren't in the gaps between the pinned contacts
    612     // now just get appended to the end of the list.
    613     toArrange.addAll(unpinnedContacts);
    614   }
    615 
    616   /**
    617    * Given an existing list of contact entries and a single entry that is to be pinned at a
    618    * particular position, return a list of {@link ContentProviderOperation}s that contains new
    619    * pinned positions for all contacts that are forced to be pinned at new positions, trying as much
    620    * as possible to keep pinned contacts at their original location.
    621    *
    622    * <p>At this point in time the pinned position of each contact in the list has already been
    623    * updated by {@link #arrangeContactsByPinnedPosition}, so we can assume that all pinned
    624    * positions(within {@link #PIN_LIMIT} are unique positive integers.
    625    */
    626   @VisibleForTesting
    627   private ArrayList<ContentProviderOperation> getReflowedPinningOperations(
    628       ArrayList<ContactEntry> list, int oldPos, int newPinPos) {
    629     final ArrayList<ContentProviderOperation> positions = new ArrayList<>();
    630     final int lowerBound = Math.min(oldPos, newPinPos);
    631     final int upperBound = Math.max(oldPos, newPinPos);
    632     for (int i = lowerBound; i <= upperBound; i++) {
    633       final ContactEntry entry = list.get(i);
    634 
    635       // Pinned positions in the database start from 1 instead of being zero-indexed like
    636       // arrays, so offset by 1.
    637       final int databasePinnedPosition = i + 1;
    638       if (entry.pinned == databasePinnedPosition) {
    639         continue;
    640       }
    641 
    642       final Uri uri = Uri.withAppendedPath(Contacts.CONTENT_URI, String.valueOf(entry.id));
    643       final ContentValues values = new ContentValues();
    644       values.put(Contacts.PINNED, databasePinnedPosition);
    645       positions.add(ContentProviderOperation.newUpdate(uri).withValues(values).build());
    646     }
    647     return positions;
    648   }
    649 
    650   @Override
    651   public void onDragStarted(int x, int y, PhoneFavoriteSquareTileView view) {
    652     setInDragging(true);
    653     final int itemIndex = contactEntries.indexOf(view.getContactEntry());
    654     popContactEntry(itemIndex);
    655   }
    656 
    657   @Override
    658   public void onDragHovered(int x, int y, PhoneFavoriteSquareTileView view) {
    659     if (view == null) {
    660       // The user is hovering over a view that is not a contact tile, no need to do
    661       // anything here.
    662       return;
    663     }
    664     final int itemIndex = contactEntries.indexOf(view.getContactEntry());
    665     if (inDragging
    666         && dragEnteredEntryIndex != itemIndex
    667         && isIndexInBound(itemIndex)
    668         && itemIndex < PIN_LIMIT
    669         && itemIndex >= 0) {
    670       markDropArea(itemIndex);
    671     }
    672   }
    673 
    674   @Override
    675   public void onDragFinished(int x, int y) {
    676     setInDragging(false);
    677     // A contact has been dragged to the RemoveView in order to be unstarred,  so simply wait
    678     // for the new contact cursor which will cause the UI to be refreshed without the unstarred
    679     // contact.
    680     if (!awaitingRemove) {
    681       handleDrop();
    682     }
    683   }
    684 
    685   @Override
    686   public void onDroppedOnRemove() {
    687     if (draggedEntry != null) {
    688       unstarAndUnpinContact(draggedEntry.lookupUri);
    689       awaitingRemove = true;
    690       Logger.get(context).logInteraction(InteractionEvent.Type.SPEED_DIAL_REMOVE_CONTACT);
    691     }
    692   }
    693 
    694   interface OnDataSetChangedForAnimationListener {
    695 
    696     void onDataSetChangedForAnimation(long... idsInPlace);
    697 
    698     void cacheOffsetsForDatasetChange();
    699   }
    700 
    701   private static class ViewTypes {
    702 
    703     static final int TILE = 0;
    704     static final int COUNT = 1;
    705   }
    706 }
    707