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