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