From aa9d670a4f076e52418cd5e404435c524713278e Mon Sep 17 00:00:00 2001 From: calderwoodra Date: Wed, 25 Apr 2018 17:21:03 -0700 Subject: Persist contacts pinned positions in speed dial. Bug: 78491298 Test: WIP PiperOrigin-RevId: 194323952 Change-Id: I6883ce1506684c93cb5538ebbc0e14aecc300a00 --- .../android/dialer/speeddial/SpeedDialAdapter.java | 30 +- .../dialer/speeddial/SpeedDialFragment.java | 20 +- .../dialer/speeddial/database/SpeedDialEntry.java | 8 +- .../database/SpeedDialEntryDatabaseHelper.java | 30 +- .../dialer/speeddial/loader/SpeedDialUiItem.java | 11 +- .../speeddial/loader/SpeedDialUiItemLoader.java | 522 ------------------ .../speeddial/loader/SpeedDialUiItemMutator.java | 597 +++++++++++++++++++++ .../speeddial/loader/UiItemLoaderComponent.java | 2 +- 8 files changed, 674 insertions(+), 546 deletions(-) delete mode 100644 java/com/android/dialer/speeddial/loader/SpeedDialUiItemLoader.java create mode 100644 java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java diff --git a/java/com/android/dialer/speeddial/SpeedDialAdapter.java b/java/com/android/dialer/speeddial/SpeedDialAdapter.java index 8a37e97dd..a382b1a6b 100644 --- a/java/com/android/dialer/speeddial/SpeedDialAdapter.java +++ b/java/com/android/dialer/speeddial/SpeedDialAdapter.java @@ -34,10 +34,10 @@ import com.android.dialer.speeddial.HeaderViewHolder.SpeedDialHeaderListener; import com.android.dialer.speeddial.SuggestionViewHolder.SuggestedContactsListener; import com.android.dialer.speeddial.draghelper.SpeedDialItemTouchHelperCallback.ItemTouchHelperAdapter; import com.android.dialer.speeddial.loader.SpeedDialUiItem; +import com.google.common.collect.ImmutableList; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; @@ -146,7 +146,13 @@ public final class SpeedDialAdapter extends RecyclerView.Adapter immutableSpeedDialUiItems) { speedDialUiItems = new ArrayList<>(); speedDialUiItems.addAll(immutableSpeedDialUiItems); - speedDialUiItems.sort((o1, o2) -> Boolean.compare(o2.isStarred(), o1.isStarred())); + speedDialUiItems.sort( + (o1, o2) -> { + if (o1.isStarred() && o2.isStarred()) { + return Integer.compare(o1.pinnedPosition().or(-1), o2.pinnedPosition().or(-1)); + } + return Boolean.compare(o2.isStarred(), o1.isStarred()); + }); positionToRowTypeMap.clear(); if (speedDialUiItems.isEmpty()) { return; @@ -168,6 +174,13 @@ public final class SpeedDialAdapter extends RecyclerView.Adapter getSpeedDialUiItems() { + if (speedDialUiItems == null || speedDialUiItems.isEmpty()) { + return ImmutableList.of(); + } + return ImmutableList.copyOf(speedDialUiItems); + } + public SpanSizeLookup getSpanSizeLookup() { return new SpanSizeLookup() { @Override @@ -189,16 +202,9 @@ public final class SpeedDialAdapter extends RecyclerView.Adapter toPosition; i--) { - Collections.swap(speedDialUiItems, i, i - 1); - } - } - // TODO(calderwoodra): store pinned positions + // fromPosition/toPosition correspond to adapter position, which is off by 1 from the list + // position b/c of the favorites header. So subtract 1 here. + speedDialUiItems.add(toPosition - 1, speedDialUiItems.remove(fromPosition - 1)); notifyItemMoved(fromPosition, toPosition); } diff --git a/java/com/android/dialer/speeddial/SpeedDialFragment.java b/java/com/android/dialer/speeddial/SpeedDialFragment.java index b74c06239..b76db1cf3 100644 --- a/java/com/android/dialer/speeddial/SpeedDialFragment.java +++ b/java/com/android/dialer/speeddial/SpeedDialFragment.java @@ -36,6 +36,7 @@ import com.android.dialer.callintent.CallInitiationType; import com.android.dialer.callintent.CallIntentBuilder; import com.android.dialer.common.FragmentUtils; import com.android.dialer.common.LogUtil; +import com.android.dialer.common.concurrent.DefaultFutureCallback; import com.android.dialer.common.concurrent.DialerExecutorComponent; import com.android.dialer.common.concurrent.SupportUiListener; import com.android.dialer.constants.ActivityRequestCodes; @@ -54,6 +55,7 @@ import com.android.dialer.speeddial.loader.SpeedDialUiItem; import com.android.dialer.speeddial.loader.UiItemLoaderComponent; import com.android.dialer.util.IntentUtil; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; /** * Fragment for displaying: @@ -143,7 +145,7 @@ public class SpeedDialFragment extends Fragment { speedDialLoaderListener.listen( getContext(), - UiItemLoaderComponent.get(getContext()).speedDialUiItemLoader().loadSpeedDialUiItems(), + UiItemLoaderComponent.get(getContext()).speedDialUiItemMutator().loadSpeedDialUiItems(), this::onSpeedDialUiItemListLoaded, throwable -> { throw new RuntimeException(throwable); @@ -158,7 +160,7 @@ public class SpeedDialFragment extends Fragment { speedDialLoaderListener.listen( getContext(), UiItemLoaderComponent.get(getContext()) - .speedDialUiItemLoader() + .speedDialUiItemMutator() .starContact(data.getData()), this::onSpeedDialUiItemListLoaded, throwable -> { @@ -173,7 +175,7 @@ public class SpeedDialFragment extends Fragment { // TODO(calderwoodra): Use DiffUtil to properly update and animate the change adapter.setSpeedDialUiItems( UiItemLoaderComponent.get(getContext()) - .speedDialUiItemLoader() + .speedDialUiItemMutator() .insertDuoChannels(getContext(), speedDialUiItems)); adapter.notifyDataSetChanged(); if (getActivity() != null) { @@ -187,6 +189,18 @@ public class SpeedDialFragment extends Fragment { super.onPause(); contextMenu.hideMenu(); contextMenuBackground.setVisibility(View.GONE); + Futures.addCallback( + DialerExecutorComponent.get(getContext()) + .backgroundExecutor() + .submit( + () -> { + UiItemLoaderComponent.get(getContext()) + .speedDialUiItemMutator() + .updatePinnedPosition(adapter.getSpeedDialUiItems()); + return null; + }), + new DefaultFutureCallback<>(), + DialerExecutorComponent.get(getContext()).backgroundExecutor()); } @Override diff --git a/java/com/android/dialer/speeddial/database/SpeedDialEntry.java b/java/com/android/dialer/speeddial/database/SpeedDialEntry.java index 89aed8f37..181f9eca7 100644 --- a/java/com/android/dialer/speeddial/database/SpeedDialEntry.java +++ b/java/com/android/dialer/speeddial/database/SpeedDialEntry.java @@ -20,6 +20,7 @@ import android.provider.ContactsContract.CommonDataKinds.Phone; import android.support.annotation.IntDef; import android.support.annotation.Nullable; import com.google.auto.value.AutoValue; +import com.google.common.base.Optional; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -35,6 +36,9 @@ public abstract class SpeedDialEntry { @Nullable public abstract Long id(); + /** Position the contact is pinned to in the UI. Will be absent if it hasn't be set yet. */ + public abstract Optional pinnedPosition(); + /** @see {@link Contacts#_ID} */ public abstract long contactId(); @@ -53,7 +57,7 @@ public abstract class SpeedDialEntry { public abstract Builder toBuilder(); public static Builder builder() { - return new AutoValue_SpeedDialEntry.Builder(); + return new AutoValue_SpeedDialEntry.Builder().setPinnedPosition(Optional.absent()); } /** Builder class for speed dial entry. */ @@ -62,6 +66,8 @@ public abstract class SpeedDialEntry { public abstract Builder setId(Long id); + public abstract Builder setPinnedPosition(Optional pinnedPosition); + public abstract Builder setContactId(long contactId); public abstract Builder setLookupKey(String lookupKey); diff --git a/java/com/android/dialer/speeddial/database/SpeedDialEntryDatabaseHelper.java b/java/com/android/dialer/speeddial/database/SpeedDialEntryDatabaseHelper.java index 544bb3613..1416a203d 100644 --- a/java/com/android/dialer/speeddial/database/SpeedDialEntryDatabaseHelper.java +++ b/java/com/android/dialer/speeddial/database/SpeedDialEntryDatabaseHelper.java @@ -25,6 +25,7 @@ import android.text.TextUtils; import com.android.dialer.common.Assert; import com.android.dialer.common.database.Selection; import com.android.dialer.speeddial.database.SpeedDialEntry.Channel; +import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.util.ArrayList; @@ -38,12 +39,20 @@ import java.util.List; public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper implements SpeedDialEntryDao { + /** + * If the pinned position is absent, then we need to write an impossible value in the table like + * -1 so that it doesn't default to 0. When we read this value from the table, we'll translate it + * to Optional.absent() in the resulting {@link SpeedDialEntry}. + */ + private static final int PINNED_POSITION_ABSENT = -1; + private static final int DATABASE_VERSION = 2; private static final String DATABASE_NAME = "CPSpeedDialEntry"; // Column names private static final String TABLE_NAME = "speed_dial_entries"; private static final String ID = "id"; + private static final String PINNED_POSITION = "pinned_position"; private static final String CONTACT_ID = "contact_id"; private static final String LOOKUP_KEY = "lookup_key"; private static final String PHONE_NUMBER = "phone_number"; @@ -53,12 +62,13 @@ public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper // Column positions private static final int POSITION_ID = 0; - private static final int POSITION_CONTACT_ID = 1; - private static final int POSITION_LOOKUP_KEY = 2; - private static final int POSITION_PHONE_NUMBER = 3; - private static final int POSITION_PHONE_TYPE = 4; - private static final int POSITION_PHONE_LABEL = 5; - private static final int POSITION_PHONE_TECHNOLOGY = 6; + private static final int POSITION_PINNED_POSITION = 1; + private static final int POSITION_CONTACT_ID = 2; + private static final int POSITION_LOOKUP_KEY = 3; + private static final int POSITION_PHONE_NUMBER = 4; + private static final int POSITION_PHONE_TYPE = 5; + private static final int POSITION_PHONE_LABEL = 6; + private static final int POSITION_PHONE_TECHNOLOGY = 7; // Create Table Query private static final String CREATE_TABLE_SQL = @@ -66,6 +76,7 @@ public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper + TABLE_NAME + " (" + (ID + " integer primary key, ") + + (PINNED_POSITION + " integer, ") + (CONTACT_ID + " integer, ") + (LOOKUP_KEY + " text, ") + (PHONE_NUMBER + " text, ") @@ -119,11 +130,17 @@ public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper .build(); } + Optional pinnedPosition = Optional.of(cursor.getInt(POSITION_PINNED_POSITION)); + if (pinnedPosition.or(PINNED_POSITION_ABSENT) == PINNED_POSITION_ABSENT) { + pinnedPosition = Optional.absent(); + } + SpeedDialEntry entry = SpeedDialEntry.builder() .setDefaultChannel(channel) .setContactId(cursor.getLong(POSITION_CONTACT_ID)) .setLookupKey(cursor.getString(POSITION_LOOKUP_KEY)) + .setPinnedPosition(pinnedPosition) .setId(cursor.getLong(POSITION_ID)) .build(); entries.add(entry); @@ -226,6 +243,7 @@ public final class SpeedDialEntryDatabaseHelper extends SQLiteOpenHelper if (includeId) { values.put(ID, entry.id()); } + values.put(PINNED_POSITION, entry.pinnedPosition().or(PINNED_POSITION_ABSENT)); values.put(CONTACT_ID, entry.contactId()); values.put(LOOKUP_KEY, entry.lookupKey()); if (entry.defaultChannel() != null) { diff --git a/java/com/android/dialer/speeddial/loader/SpeedDialUiItem.java b/java/com/android/dialer/speeddial/loader/SpeedDialUiItem.java index 9bda3fb31..a2bdfb89a 100644 --- a/java/com/android/dialer/speeddial/loader/SpeedDialUiItem.java +++ b/java/com/android/dialer/speeddial/loader/SpeedDialUiItem.java @@ -25,6 +25,7 @@ import com.android.dialer.common.Assert; import com.android.dialer.speeddial.database.SpeedDialEntry; import com.android.dialer.speeddial.database.SpeedDialEntry.Channel; import com.google.auto.value.AutoValue; +import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; @@ -83,7 +84,9 @@ public abstract class SpeedDialUiItem { } public static Builder builder() { - return new AutoValue_SpeedDialUiItem.Builder().setChannels(ImmutableList.of()); + return new AutoValue_SpeedDialUiItem.Builder() + .setChannels(ImmutableList.of()) + .setPinnedPosition(Optional.absent()); } /** @@ -139,6 +142,7 @@ public abstract class SpeedDialUiItem { public SpeedDialEntry buildSpeedDialEntry() { return SpeedDialEntry.builder() .setId(speedDialEntryId()) + .setPinnedPosition(pinnedPosition()) .setLookupKey(lookupKey()) .setContactId(contactId()) .setDefaultChannel(defaultChannel()) @@ -212,6 +216,9 @@ public abstract class SpeedDialUiItem { @Nullable public abstract Long speedDialEntryId(); + /** @see SpeedDialEntry#pinnedPosition() */ + public abstract Optional pinnedPosition(); + /** @see android.provider.ContactsContract.Contacts#DISPLAY_NAME */ public abstract String name(); @@ -255,6 +262,8 @@ public abstract class SpeedDialUiItem { /** Set to null if {@link #isStarred()} is false. */ public abstract Builder setSpeedDialEntryId(@Nullable Long id); + public abstract Builder setPinnedPosition(Optional pinnedPosition); + public abstract Builder setName(String name); public abstract Builder setContactId(long contactId); diff --git a/java/com/android/dialer/speeddial/loader/SpeedDialUiItemLoader.java b/java/com/android/dialer/speeddial/loader/SpeedDialUiItemLoader.java deleted file mode 100644 index 921468773..000000000 --- a/java/com/android/dialer/speeddial/loader/SpeedDialUiItemLoader.java +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License - */ - -package com.android.dialer.speeddial.loader; - -import android.annotation.TargetApi; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build.VERSION_CODES; -import android.provider.ContactsContract; -import android.provider.ContactsContract.CommonDataKinds.Phone; -import android.provider.ContactsContract.Contacts; -import android.support.annotation.MainThread; -import android.support.annotation.WorkerThread; -import android.util.ArrayMap; -import android.util.ArraySet; -import com.android.contacts.common.preference.ContactsPreferences; -import com.android.dialer.common.Assert; -import com.android.dialer.common.LogUtil; -import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor; -import com.android.dialer.common.concurrent.DialerExecutor.SuccessListener; -import com.android.dialer.common.concurrent.DialerFutureSerializer; -import com.android.dialer.common.database.Selection; -import com.android.dialer.duo.Duo; -import com.android.dialer.duo.DuoComponent; -import com.android.dialer.inject.ApplicationContext; -import com.android.dialer.speeddial.database.SpeedDialEntry; -import com.android.dialer.speeddial.database.SpeedDialEntry.Channel; -import com.android.dialer.speeddial.database.SpeedDialEntryDao; -import com.android.dialer.speeddial.database.SpeedDialEntryDatabaseHelper; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import javax.inject.Inject; -import javax.inject.Singleton; - -/** - * Loads a list of {@link SpeedDialUiItem SpeedDialUiItems}. - * - * @see #loadSpeedDialUiItems() - *
    - *
  1. Retrieve the list of {@link SpeedDialEntry} from {@link SpeedDialEntryDatabaseHelper}. - *
  2. Build a list of {@link SpeedDialUiItem} based on {@link SpeedDialEntry#lookupKey()} in - * {@link Phone#CONTENT_URI}. - *
  3. Remove any {@link SpeedDialEntry} that is no longer starred or whose contact was - * deleted. - *
  4. Update each {@link SpeedDialEntry} contact id, lookup key and channel. - *
  5. Build a list of {@link SpeedDialUiItem} from {@link Contacts#STREQUENT_PHONE_ONLY}. - *
  6. If any starred contacts in that list aren't in the {@link - * SpeedDialEntryDatabaseHelper}, insert them now. - *
  7. Notify the {@link SuccessListener} of the complete list of {@link SpeedDialUiItem - * SpeedDialContacts} composed from {@link SpeedDialEntry SpeedDialEntries} and - * non-starred {@link Contacts#STREQUENT_PHONE_ONLY}. - *
- */ -@SuppressWarnings("AndroidApiChecker") -@TargetApi(VERSION_CODES.N) -@Singleton -public final class SpeedDialUiItemLoader { - - private static final int MAX_DUO_SUGGESTIONS = 3; - - private final Context appContext; - private final ListeningExecutorService backgroundExecutor; - // Used to ensure that only one refresh flow runs at a time. - private final DialerFutureSerializer dialerFutureSerializer = new DialerFutureSerializer(); - private final ContactsPreferences contactsPreferences; - - @Inject - public SpeedDialUiItemLoader( - @ApplicationContext Context appContext, - @BackgroundExecutor ListeningExecutorService backgroundExecutor) { - this.appContext = appContext; - this.backgroundExecutor = backgroundExecutor; - this.contactsPreferences = new ContactsPreferences(appContext); - } - - /** - * Returns a {@link ListenableFuture} for a list of {@link SpeedDialUiItem SpeedDialUiItems}. This - * list is composed of starred contacts from {@link SpeedDialEntryDatabaseHelper} and suggestions - * from {@link Contacts#STREQUENT_PHONE_ONLY}. - */ - public ListenableFuture> loadSpeedDialUiItems() { - return dialerFutureSerializer.submit(this::loadSpeedDialUiItemsInternal, backgroundExecutor); - } - - /** - * Takes a contact uri from {@link Phone#CONTENT_URI} and updates {@link Phone#STARRED} to be - * true, if it isn't already or Inserts the contact into the {@link SpeedDialEntryDatabaseHelper} - */ - public ListenableFuture> starContact(Uri contactUri) { - return dialerFutureSerializer.submit( - () -> insertNewContactEntry(contactUri), backgroundExecutor); - } - - @WorkerThread - private ImmutableList insertNewContactEntry(Uri contactUri) { - Assert.isWorkerThread(); - contactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); - try (Cursor cursor = - appContext - .getContentResolver() - .query( - contactUri, - SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), - null, - null, - null)) { - if (cursor == null) { - LogUtil.e("SpeedDialUiItemLoader.insertNewContactEntry", "Cursor was null"); - return loadSpeedDialUiItemsInternal(); - } - Assert.checkArgument(cursor.moveToFirst(), "Cursor should never be empty"); - SpeedDialUiItem item = SpeedDialUiItem.fromCursor(cursor); - - // Star the contact if it isn't starred already, then return. - if (!item.isStarred()) { - ContentValues values = new ContentValues(); - values.put(Phone.STARRED, "1"); - appContext - .getContentResolver() - .update( - Contacts.CONTENT_URI, - values, - Contacts._ID + " = ?", - new String[] {Long.toString(item.contactId())}); - } - - // Insert a new entry into the SpeedDialEntry database - getSpeedDialEntryDao().insert(item.buildSpeedDialEntry()); - } - return loadSpeedDialUiItemsInternal(); - } - - @WorkerThread - private ImmutableList loadSpeedDialUiItemsInternal() { - Assert.isWorkerThread(); - contactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); - SpeedDialEntryDao db = getSpeedDialEntryDao(); - - // This is the list of contacts that we will display to the user - List speedDialUiItems = new ArrayList<>(); - - // We'll use these lists to update the SpeedDialEntry database - List entriesToInsert = new ArrayList<>(); - List entriesToUpdate = new ArrayList<>(); - List entriesToDelete = new ArrayList<>(); - - // Get all SpeedDialEntries and update their contact ids and lookupkeys. - List entries = db.getAllEntries(); - entries = updateContactIdsAndLookupKeys(entries); - - // Build SpeedDialUiItems from our updated entries. - Map entriesToUiItems = getSpeedDialUiItemsFromEntries(entries); - Assert.checkArgument( - entries.size() == entriesToUiItems.size(), - "Updated entries are incomplete: " + entries.size() + " != " + entriesToUiItems.size()); - - // Mark the SpeedDialEntries to be updated or deleted - for (SpeedDialEntry entry : entries) { - SpeedDialUiItem contact = entriesToUiItems.get(entry); - // Remove contacts that no longer exist or are no longer starred - if (contact == null || !contact.isStarred()) { - entriesToDelete.add(entry.id()); - continue; - } - - // Contact exists, so update its entry in SpeedDialEntry Database - entriesToUpdate.add( - entry - .toBuilder() - .setLookupKey(contact.lookupKey()) - .setContactId(contact.contactId()) - .setDefaultChannel(contact.defaultChannel()) - .build()); - - // These are our existing starred entries - speedDialUiItems.add(contact); - } - - // Get all Strequent Contacts - List strequentContacts = getStrequentContacts(); - - // For each contact, if it isn't starred, add it as a suggestion. - // If it is starred and not already accounted for above, then insert into the SpeedDialEntry DB. - for (SpeedDialUiItem contact : strequentContacts) { - if (!contact.isStarred()) { - // Add this contact as a suggestion - // TODO(77754534): improve suggestions beyond just first channel - speedDialUiItems.add( - contact.toBuilder().setDefaultChannel(contact.channels().get(0)).build()); - - } else if (speedDialUiItems.stream().noneMatch(c -> c.contactId() == contact.contactId())) { - entriesToInsert.add(contact.buildSpeedDialEntry()); - - // These are our newly starred contacts - speedDialUiItems.add(contact); - } - } - - ImmutableMap insertedEntriesToIdsMap = - db.insertUpdateAndDelete( - ImmutableList.copyOf(entriesToInsert), - ImmutableList.copyOf(entriesToUpdate), - ImmutableList.copyOf(entriesToDelete)); - return speedDialUiItemsWithUpdatedIds(speedDialUiItems, insertedEntriesToIdsMap); - } - - /** - * Since newly starred contacts sometimes aren't in the SpeedDialEntry database, we couldn't set - * their ids when we created our initial list of {@link SpeedDialUiItem speedDialUiItems}. Now - * that we've inserted the entries into the database and we have their ids, build a new list of - * speedDialUiItems with the now known ids. - */ - private ImmutableList speedDialUiItemsWithUpdatedIds( - List speedDialUiItems, - ImmutableMap insertedEntriesToIdsMap) { - if (insertedEntriesToIdsMap.isEmpty()) { - // There were no newly inserted entries, so all entries ids are set already. - return ImmutableList.copyOf(speedDialUiItems); - } - - ImmutableList.Builder updatedItems = ImmutableList.builder(); - for (SpeedDialUiItem speedDialUiItem : speedDialUiItems) { - SpeedDialEntry entry = speedDialUiItem.buildSpeedDialEntry(); - if (insertedEntriesToIdsMap.containsKey(entry)) { - // Get the id for newly inserted entry, update our SpeedDialUiItem and add it to our list - Long id = Assert.isNotNull(insertedEntriesToIdsMap.get(entry)); - updatedItems.add(speedDialUiItem.toBuilder().setSpeedDialEntryId(id).build()); - continue; - } - - // Starred contacts that aren't in the map, should already have speed dial entry ids. - // Non-starred contacts (suggestions) aren't in the speed dial entry database, so they - // shouldn't have speed dial entry ids. - Assert.checkArgument( - speedDialUiItem.isStarred() == (speedDialUiItem.speedDialEntryId() != null), - "Contact must be starred with a speed dial entry id, or not starred with no id " - + "(suggested contacts)"); - updatedItems.add(speedDialUiItem); - } - return updatedItems.build(); - } - - /** - * Returns the same list of SpeedDialEntries that are passed in except their contact ids and - * lookup keys are updated to current values. - * - *

Unfortunately, we need to look up each contact individually to update the contact id and - * lookup key. Luckily though, this query is highly optimized on the framework side and very - * quick. - */ - @WorkerThread - private List updateContactIdsAndLookupKeys(List entries) { - Assert.isWorkerThread(); - List updatedEntries = new ArrayList<>(); - for (SpeedDialEntry entry : entries) { - try (Cursor cursor = - appContext - .getContentResolver() - .query( - Contacts.getLookupUri(entry.contactId(), entry.lookupKey()), - new String[] {Contacts._ID, Contacts.LOOKUP_KEY}, - null, - null, - null)) { - if (cursor == null) { - LogUtil.e("SpeedDialUiItemLoader.updateContactIdsAndLookupKeys", "null cursor"); - return new ArrayList<>(); - } - if (cursor.getCount() == 0) { - // No need to update this entry, the contact was deleted. We'll clear it up later. - updatedEntries.add(entry); - continue; - } - // Since all cursor rows will be have the same contact id and lookup key, just grab the - // first one. - cursor.moveToFirst(); - updatedEntries.add( - entry - .toBuilder() - .setContactId(cursor.getLong(0)) - .setLookupKey(cursor.getString(1)) - .build()); - } - } - return updatedEntries; - } - - /** - * Returns a map of SpeedDialEntries to their corresponding SpeedDialUiItems. Mappings to null - * elements imply that the contact was deleted. - */ - @WorkerThread - private Map getSpeedDialUiItemsFromEntries( - List entries) { - Assert.isWorkerThread(); - // Fetch the contact ids from the SpeedDialEntries - Set contactIds = new ArraySet<>(); - entries.forEach(entry -> contactIds.add(Long.toString(entry.contactId()))); - if (contactIds.isEmpty()) { - return new ArrayMap<>(); - } - - // Build SpeedDialUiItems from those contact ids and map them to their entries - Selection selection = - Selection.builder().and(Selection.column(Phone.CONTACT_ID).in(contactIds)).build(); - try (Cursor cursor = - appContext - .getContentResolver() - .query( - Phone.CONTENT_URI, - SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), - selection.getSelection(), - selection.getSelectionArgs(), - null)) { - Map map = new ArrayMap<>(); - for (cursor.moveToFirst(); !cursor.isAfterLast(); /* Iterate in the loop */ ) { - SpeedDialUiItem item = SpeedDialUiItem.fromCursor(cursor); - for (SpeedDialEntry entry : entries) { - if (entry.contactId() == item.contactId()) { - // Update the id to match it's corresponding SpeedDialEntry. - SpeedDialUiItem.Builder entrySpeedDialItem = - item.toBuilder().setSpeedDialEntryId(entry.id()); - - // Preserve the default channel if it didn't change/still exists - Channel defaultChannel = entry.defaultChannel(); - if (defaultChannel != null) { - if (item.channels().contains(defaultChannel) - || isValidDuoDefaultChannel(item.channels(), defaultChannel)) { - entrySpeedDialItem.setDefaultChannel(defaultChannel); - } - } - - // It's impossible for two contacts to exist with the same contact id, so if this entry - // was previously matched to a SpeedDialUiItem and is being matched again, something - // went horribly wrong. - Assert.checkArgument( - map.put(entry, entrySpeedDialItem.build()) == null, - "Each SpeedDialEntry only has one correct SpeedDialUiItem"); - } - } - } - - // Contact must have been deleted - for (SpeedDialEntry entry : entries) { - map.putIfAbsent(entry, null); - } - return map; - } - } - - /** - * Since we can't check duo reachabliity on background threads, we have to assume the contact is - * still duo reachable. So we just check it is and return true if the Duo number is still - * associated with the contact. - */ - private static boolean isValidDuoDefaultChannel( - ImmutableList channels, Channel defaultChannel) { - if (defaultChannel.technology() != Channel.DUO) { - return false; - } - - for (Channel channel : channels) { - if (channel.number().equals(defaultChannel.number())) { - return true; - } - } - return false; - } - - @WorkerThread - private List getStrequentContacts() { - Assert.isWorkerThread(); - Set contactIds = new ArraySet<>(); - - // Fetch the contact ids of all strequent contacts - Uri strequentUri = - Contacts.CONTENT_STREQUENT_URI - .buildUpon() - .appendQueryParameter(ContactsContract.STREQUENT_PHONE_ONLY, "true") - .build(); - try (Cursor cursor = - appContext - .getContentResolver() - .query(strequentUri, new String[] {Phone.CONTACT_ID}, null, null, null)) { - if (cursor == null) { - LogUtil.e("SpeedDialUiItemLoader.getStrequentContacts", "null cursor"); - return new ArrayList<>(); - } - if (cursor.getCount() == 0) { - return new ArrayList<>(); - } - for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { - contactIds.add(Long.toString(cursor.getLong(0))); - } - } - - // Build SpeedDialUiItems from those contact ids - Selection selection = - Selection.builder().and(Selection.column(Phone.CONTACT_ID).in(contactIds)).build(); - try (Cursor cursor = - appContext - .getContentResolver() - .query( - Phone.CONTENT_URI, - SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), - selection.getSelection(), - selection.getSelectionArgs(), - null)) { - List contacts = new ArrayList<>(); - if (cursor == null) { - LogUtil.e("SpeedDialUiItemLoader.getStrequentContacts", "null cursor"); - return new ArrayList<>(); - } - if (cursor.getCount() == 0) { - return contacts; - } - for (cursor.moveToFirst(); !cursor.isAfterLast(); /* Iterate in the loop */ ) { - contacts.add(SpeedDialUiItem.fromCursor(cursor)); - } - return contacts; - } - } - - /** - * Returns a new list with duo reachable channels inserted. Duo channels won't replace ViLTE - * channels. - */ - @MainThread - public ImmutableList insertDuoChannels( - Context context, ImmutableList speedDialUiItems) { - Assert.isMainThread(); - - Duo duo = DuoComponent.get(context).getDuo(); - int maxDuoSuggestions = MAX_DUO_SUGGESTIONS; - - ImmutableList.Builder newSpeedDialItemList = ImmutableList.builder(); - // for each existing item - for (SpeedDialUiItem item : speedDialUiItems) { - // If the item is a suggestion - if (!item.isStarred()) { - // And duo reachable, insert a duo suggestion - if (maxDuoSuggestions > 0 && duo.isReachable(context, item.defaultChannel().number())) { - maxDuoSuggestions--; - Channel defaultChannel = - item.defaultChannel().toBuilder().setTechnology(Channel.DUO).build(); - newSpeedDialItemList.add(item.toBuilder().setDefaultChannel(defaultChannel).build()); - } - // Insert the voice suggestion too - newSpeedDialItemList.add(item); - } else if (item.defaultChannel() == null) { - // If the contact is starred and doesn't have a default channel, insert duo channels - newSpeedDialItemList.add(insertDuoChannelsToStarredContact(context, item)); - } else { - // if starred and has a default channel, leave it as is, the user knows what they want. - newSpeedDialItemList.add(item); - } - } - return newSpeedDialItemList.build(); - } - - @MainThread - private SpeedDialUiItem insertDuoChannelsToStarredContact(Context context, SpeedDialUiItem item) { - Assert.isMainThread(); - Assert.checkArgument(item.isStarred()); - - // build a new list of channels - ImmutableList.Builder newChannelsList = ImmutableList.builder(); - Channel previousChannel = item.channels().get(0); - newChannelsList.add(previousChannel); - - for (int i = 1; i < item.channels().size(); i++) { - Channel currentChannel = item.channels().get(i); - // If the previous and current channel are voice channels, that means the previous number - // didn't have a video channel. - // If the previous number is duo reachable, insert a duo channel. - if (!previousChannel.isVideoTechnology() - && !currentChannel.isVideoTechnology() - && DuoComponent.get(context).getDuo().isReachable(context, previousChannel.number())) { - newChannelsList.add(previousChannel.toBuilder().setTechnology(Channel.DUO).build()); - } - newChannelsList.add(currentChannel); - previousChannel = currentChannel; - } - - // Check the last channel - if (!previousChannel.isVideoTechnology() - && DuoComponent.get(context).getDuo().isReachable(context, previousChannel.number())) { - newChannelsList.add(previousChannel.toBuilder().setTechnology(Channel.DUO).build()); - } - return item.toBuilder().setChannels(newChannelsList.build()).build(); - } - - private SpeedDialEntryDao getSpeedDialEntryDao() { - return new SpeedDialEntryDatabaseHelper(appContext); - } - - private boolean isPrimaryDisplayNameOrder() { - return contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY; - } -} diff --git a/java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java b/java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java new file mode 100644 index 000000000..5dae2efab --- /dev/null +++ b/java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java @@ -0,0 +1,597 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.dialer.speeddial.loader; + +import android.annotation.TargetApi; +import android.content.ContentProviderOperation; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build.VERSION_CODES; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.support.annotation.MainThread; +import android.support.annotation.WorkerThread; +import android.util.ArrayMap; +import android.util.ArraySet; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor; +import com.android.dialer.common.concurrent.DialerExecutor.SuccessListener; +import com.android.dialer.common.concurrent.DialerFutureSerializer; +import com.android.dialer.common.database.Selection; +import com.android.dialer.duo.Duo; +import com.android.dialer.duo.DuoComponent; +import com.android.dialer.inject.ApplicationContext; +import com.android.dialer.speeddial.database.SpeedDialEntry; +import com.android.dialer.speeddial.database.SpeedDialEntry.Channel; +import com.android.dialer.speeddial.database.SpeedDialEntryDao; +import com.android.dialer.speeddial.database.SpeedDialEntryDatabaseHelper; +import com.google.common.base.Optional; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Loads a list of {@link SpeedDialUiItem SpeedDialUiItems}. + * + * @see #loadSpeedDialUiItems() + *

    + *
  1. Retrieve the list of {@link SpeedDialEntry} from {@link SpeedDialEntryDatabaseHelper}. + *
  2. Build a list of {@link SpeedDialUiItem} based on {@link SpeedDialEntry#lookupKey()} in + * {@link Phone#CONTENT_URI}. + *
  3. Remove any {@link SpeedDialEntry} that is no longer starred or whose contact was + * deleted. + *
  4. Update each {@link SpeedDialEntry} contact id, lookup key and channel. + *
  5. Build a list of {@link SpeedDialUiItem} from {@link Contacts#STREQUENT_PHONE_ONLY}. + *
  6. If any starred contacts in that list aren't in the {@link + * SpeedDialEntryDatabaseHelper}, insert them now. + *
  7. Notify the {@link SuccessListener} of the complete list of {@link SpeedDialUiItem + * SpeedDialContacts} composed from {@link SpeedDialEntry SpeedDialEntries} and + * non-starred {@link Contacts#STREQUENT_PHONE_ONLY}. + *
+ */ +@SuppressWarnings("AndroidApiChecker") +@TargetApi(VERSION_CODES.N) +@Singleton +public final class SpeedDialUiItemMutator { + + private static final int MAX_DUO_SUGGESTIONS = 3; + + private final Context appContext; + private final ListeningExecutorService backgroundExecutor; + // Used to ensure that only one refresh flow runs at a time. + private final DialerFutureSerializer dialerFutureSerializer = new DialerFutureSerializer(); + private final ContactsPreferences contactsPreferences; + + @Inject + public SpeedDialUiItemMutator( + @ApplicationContext Context appContext, + @BackgroundExecutor ListeningExecutorService backgroundExecutor) { + this.appContext = appContext; + this.backgroundExecutor = backgroundExecutor; + this.contactsPreferences = new ContactsPreferences(appContext); + } + + /** + * Returns a {@link ListenableFuture} for a list of {@link SpeedDialUiItem SpeedDialUiItems}. This + * list is composed of starred contacts from {@link SpeedDialEntryDatabaseHelper} and suggestions + * from {@link Contacts#STREQUENT_PHONE_ONLY}. + */ + public ListenableFuture> loadSpeedDialUiItems() { + return dialerFutureSerializer.submit(this::loadSpeedDialUiItemsInternal, backgroundExecutor); + } + + /** + * Takes a contact uri from {@link Phone#CONTENT_URI} and updates {@link Phone#STARRED} to be + * true, if it isn't already or Inserts the contact into the {@link SpeedDialEntryDatabaseHelper} + */ + public ListenableFuture> starContact(Uri contactUri) { + return dialerFutureSerializer.submit( + () -> insertNewContactEntry(contactUri), backgroundExecutor); + } + + @WorkerThread + private ImmutableList insertNewContactEntry(Uri contactUri) { + Assert.isWorkerThread(); + contactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + try (Cursor cursor = + appContext + .getContentResolver() + .query( + contactUri, + SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), + null, + null, + null)) { + if (cursor == null) { + LogUtil.e("SpeedDialUiItemMutator.insertNewContactEntry", "Cursor was null"); + return loadSpeedDialUiItemsInternal(); + } + Assert.checkArgument(cursor.moveToFirst(), "Cursor should never be empty"); + SpeedDialUiItem item = SpeedDialUiItem.fromCursor(cursor); + + // Star the contact if it isn't starred already, then return. + if (!item.isStarred()) { + ContentValues values = new ContentValues(); + values.put(Phone.STARRED, "1"); + appContext + .getContentResolver() + .update( + Contacts.CONTENT_URI, + values, + Contacts._ID + " = ?", + new String[] {Long.toString(item.contactId())}); + } + + // Insert a new entry into the SpeedDialEntry database + getSpeedDialEntryDao().insert(item.buildSpeedDialEntry()); + } + return loadSpeedDialUiItemsInternal(); + } + + @WorkerThread + private ImmutableList loadSpeedDialUiItemsInternal() { + Assert.isWorkerThread(); + contactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + SpeedDialEntryDao db = getSpeedDialEntryDao(); + + // This is the list of contacts that we will display to the user + List speedDialUiItems = new ArrayList<>(); + + // We'll use these lists to update the SpeedDialEntry database + List entriesToInsert = new ArrayList<>(); + List entriesToUpdate = new ArrayList<>(); + List entriesToDelete = new ArrayList<>(); + + // Get all SpeedDialEntries and update their contact ids and lookupkeys. + List entries = db.getAllEntries(); + entries = updateContactIdsAndLookupKeys(entries); + + // Build SpeedDialUiItems from our updated entries. + Map entriesToUiItems = getSpeedDialUiItemsFromEntries(entries); + Assert.checkArgument( + entries.size() == entriesToUiItems.size(), + "Updated entries are incomplete: " + entries.size() + " != " + entriesToUiItems.size()); + + // Mark the SpeedDialEntries to be updated or deleted + for (SpeedDialEntry entry : entries) { + SpeedDialUiItem contact = entriesToUiItems.get(entry); + // Remove contacts that no longer exist or are no longer starred + if (contact == null || !contact.isStarred()) { + entriesToDelete.add(entry.id()); + continue; + } + + // Contact exists, so update its entry in SpeedDialEntry Database + entriesToUpdate.add( + entry + .toBuilder() + .setLookupKey(contact.lookupKey()) + .setContactId(contact.contactId()) + .setDefaultChannel(contact.defaultChannel()) + .build()); + + // These are our existing starred entries + speedDialUiItems.add(contact); + } + + // Get all Strequent Contacts + List strequentContacts = getStrequentContacts(); + + // For each contact, if it isn't starred, add it as a suggestion. + // If it is starred and not already accounted for above, then insert into the SpeedDialEntry DB. + for (SpeedDialUiItem contact : strequentContacts) { + if (!contact.isStarred()) { + // Add this contact as a suggestion + // TODO(77754534): improve suggestions beyond just first channel + speedDialUiItems.add( + contact.toBuilder().setDefaultChannel(contact.channels().get(0)).build()); + + } else if (speedDialUiItems.stream().noneMatch(c -> c.contactId() == contact.contactId())) { + entriesToInsert.add(contact.buildSpeedDialEntry()); + + // These are our newly starred contacts + speedDialUiItems.add(contact); + } + } + + ImmutableMap insertedEntriesToIdsMap = + db.insertUpdateAndDelete( + ImmutableList.copyOf(entriesToInsert), + ImmutableList.copyOf(entriesToUpdate), + ImmutableList.copyOf(entriesToDelete)); + return speedDialUiItemsWithUpdatedIds(speedDialUiItems, insertedEntriesToIdsMap); + } + + /** + * Since newly starred contacts sometimes aren't in the SpeedDialEntry database, we couldn't set + * their ids when we created our initial list of {@link SpeedDialUiItem speedDialUiItems}. Now + * that we've inserted the entries into the database and we have their ids, build a new list of + * speedDialUiItems with the now known ids. + */ + private ImmutableList speedDialUiItemsWithUpdatedIds( + List speedDialUiItems, + ImmutableMap insertedEntriesToIdsMap) { + if (insertedEntriesToIdsMap.isEmpty()) { + // There were no newly inserted entries, so all entries ids are set already. + return ImmutableList.copyOf(speedDialUiItems); + } + + ImmutableList.Builder updatedItems = ImmutableList.builder(); + for (SpeedDialUiItem speedDialUiItem : speedDialUiItems) { + SpeedDialEntry entry = speedDialUiItem.buildSpeedDialEntry(); + if (insertedEntriesToIdsMap.containsKey(entry)) { + // Get the id for newly inserted entry, update our SpeedDialUiItem and add it to our list + Long id = Assert.isNotNull(insertedEntriesToIdsMap.get(entry)); + updatedItems.add(speedDialUiItem.toBuilder().setSpeedDialEntryId(id).build()); + continue; + } + + // Starred contacts that aren't in the map, should already have speed dial entry ids. + // Non-starred contacts (suggestions) aren't in the speed dial entry database, so they + // shouldn't have speed dial entry ids. + Assert.checkArgument( + speedDialUiItem.isStarred() == (speedDialUiItem.speedDialEntryId() != null), + "Contact must be starred with a speed dial entry id, or not starred with no id " + + "(suggested contacts)"); + updatedItems.add(speedDialUiItem); + } + return updatedItems.build(); + } + + /** + * Returns the same list of SpeedDialEntries that are passed in except their contact ids and + * lookup keys are updated to current values. + * + *

Unfortunately, we need to look up each contact individually to update the contact id and + * lookup key. Luckily though, this query is highly optimized on the framework side and very + * quick. + */ + @WorkerThread + private List updateContactIdsAndLookupKeys(List entries) { + Assert.isWorkerThread(); + List updatedEntries = new ArrayList<>(); + for (SpeedDialEntry entry : entries) { + try (Cursor cursor = + appContext + .getContentResolver() + .query( + Contacts.getLookupUri(entry.contactId(), entry.lookupKey()), + new String[] {Contacts._ID, Contacts.LOOKUP_KEY}, + null, + null, + null)) { + if (cursor == null) { + LogUtil.e("SpeedDialUiItemMutator.updateContactIdsAndLookupKeys", "null cursor"); + return new ArrayList<>(); + } + if (cursor.getCount() == 0) { + // No need to update this entry, the contact was deleted. We'll clear it up later. + updatedEntries.add(entry); + continue; + } + // Since all cursor rows will be have the same contact id and lookup key, just grab the + // first one. + cursor.moveToFirst(); + updatedEntries.add( + entry + .toBuilder() + .setContactId(cursor.getLong(0)) + .setLookupKey(cursor.getString(1)) + .build()); + } + } + return updatedEntries; + } + + /** + * Returns a map of SpeedDialEntries to their corresponding SpeedDialUiItems. Mappings to null + * elements imply that the contact was deleted. + */ + @WorkerThread + private Map getSpeedDialUiItemsFromEntries( + List entries) { + Assert.isWorkerThread(); + // Fetch the contact ids from the SpeedDialEntries + Set contactIds = new ArraySet<>(); + entries.forEach(entry -> contactIds.add(Long.toString(entry.contactId()))); + if (contactIds.isEmpty()) { + return new ArrayMap<>(); + } + + // Build SpeedDialUiItems from those contact ids and map them to their entries + Selection selection = + Selection.builder().and(Selection.column(Phone.CONTACT_ID).in(contactIds)).build(); + try (Cursor cursor = + appContext + .getContentResolver() + .query( + Phone.CONTENT_URI, + SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), + selection.getSelection(), + selection.getSelectionArgs(), + null)) { + Map map = new ArrayMap<>(); + for (cursor.moveToFirst(); !cursor.isAfterLast(); /* Iterate in the loop */ ) { + SpeedDialUiItem item = SpeedDialUiItem.fromCursor(cursor); + for (SpeedDialEntry entry : entries) { + if (entry.contactId() == item.contactId()) { + // Update the id and pinned position to match it's corresponding SpeedDialEntry. + SpeedDialUiItem.Builder entrySpeedDialItem = + item.toBuilder() + .setSpeedDialEntryId(entry.id()) + .setPinnedPosition(entry.pinnedPosition()); + + // Preserve the default channel if it didn't change/still exists + Channel defaultChannel = entry.defaultChannel(); + if (defaultChannel != null) { + if (item.channels().contains(defaultChannel) + || isValidDuoDefaultChannel(item.channels(), defaultChannel)) { + entrySpeedDialItem.setDefaultChannel(defaultChannel); + } + } + + // It's impossible for two contacts to exist with the same contact id, so if this entry + // was previously matched to a SpeedDialUiItem and is being matched again, something + // went horribly wrong. + Assert.checkArgument( + map.put(entry, entrySpeedDialItem.build()) == null, + "Each SpeedDialEntry only has one correct SpeedDialUiItem"); + } + } + } + + // Contact must have been deleted + for (SpeedDialEntry entry : entries) { + map.putIfAbsent(entry, null); + } + return map; + } + } + + /** + * Since we can't check duo reachabliity on background threads, we have to assume the contact is + * still duo reachable. So we just check it is and return true if the Duo number is still + * associated with the contact. + */ + private static boolean isValidDuoDefaultChannel( + ImmutableList channels, Channel defaultChannel) { + if (defaultChannel.technology() != Channel.DUO) { + return false; + } + + for (Channel channel : channels) { + if (channel.number().equals(defaultChannel.number())) { + return true; + } + } + return false; + } + + @WorkerThread + private List getStrequentContacts() { + Assert.isWorkerThread(); + Set contactIds = new ArraySet<>(); + + // Fetch the contact ids of all strequent contacts + Uri strequentUri = + Contacts.CONTENT_STREQUENT_URI + .buildUpon() + .appendQueryParameter(ContactsContract.STREQUENT_PHONE_ONLY, "true") + .build(); + try (Cursor cursor = + appContext + .getContentResolver() + .query(strequentUri, new String[] {Phone.CONTACT_ID}, null, null, null)) { + if (cursor == null) { + LogUtil.e("SpeedDialUiItemMutator.getStrequentContacts", "null cursor"); + return new ArrayList<>(); + } + if (cursor.getCount() == 0) { + return new ArrayList<>(); + } + for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { + contactIds.add(Long.toString(cursor.getLong(0))); + } + } + + // Build SpeedDialUiItems from those contact ids + Selection selection = + Selection.builder().and(Selection.column(Phone.CONTACT_ID).in(contactIds)).build(); + try (Cursor cursor = + appContext + .getContentResolver() + .query( + Phone.CONTENT_URI, + SpeedDialUiItem.getPhoneProjection(isPrimaryDisplayNameOrder()), + selection.getSelection(), + selection.getSelectionArgs(), + null)) { + List contacts = new ArrayList<>(); + if (cursor == null) { + LogUtil.e("SpeedDialUiItemMutator.getStrequentContacts", "null cursor"); + return new ArrayList<>(); + } + if (cursor.getCount() == 0) { + return contacts; + } + for (cursor.moveToFirst(); !cursor.isAfterLast(); /* Iterate in the loop */ ) { + contacts.add(SpeedDialUiItem.fromCursor(cursor)); + } + return contacts; + } + } + + /** + * Persists the position of the {@link SpeedDialUiItem items} as the pinned position according to + * the order they were passed in. + */ + @WorkerThread + public void updatePinnedPosition(List speedDialUiItems) { + Assert.isWorkerThread(); + if (speedDialUiItems == null || speedDialUiItems.isEmpty()) { + return; + } + + // Update the positions in the SpeedDialEntry database + ImmutableList.Builder entriesToUpdate = ImmutableList.builder(); + for (int i = 0; i < speedDialUiItems.size(); i++) { + SpeedDialUiItem item = speedDialUiItems.get(i); + if (item.isStarred()) { + entriesToUpdate.add( + item.buildSpeedDialEntry().toBuilder().setPinnedPosition(Optional.of(i)).build()); + } + } + getSpeedDialEntryDao().update(entriesToUpdate.build()); + + // Update the positions in CP2 + // Build a list of SpeedDialUiItems where each contact is only represented once but the order + // is maintained. For example, assume you have a list of contacts with contact ids: + // > { 1, 1, 2, 1, 2, 3 } + // This list will be reduced to: + // > { 1, 2, 3 } + // and their positions in the resulting list will be written to the CP2 Contacts.PINNED column. + List cp2SpeedDialUiItems = new ArrayList<>(); + Set contactIds = new ArraySet<>(); + for (SpeedDialUiItem item : speedDialUiItems) { + if (contactIds.add(item.contactId())) { + cp2SpeedDialUiItems.add(item); + } + } + + // Code copied from PhoneFavoritesTileAdapter#handleDrop + ArrayList operations = new ArrayList<>(); + for (int i = 0; i < cp2SpeedDialUiItems.size(); i++) { + SpeedDialUiItem item = cp2SpeedDialUiItems.get(i); + // Pinned positions in the database start from 1 instead of being zero-indexed like + // arrays, so offset by 1. + int databasePinnedPosition = i + 1; + if (item.pinnedPosition().isPresent() + && item.pinnedPosition().get() == databasePinnedPosition) { + continue; + } + + Uri uri = Uri.withAppendedPath(Contacts.CONTENT_URI, String.valueOf(item.contactId())); + ContentValues values = new ContentValues(); + values.put(Contacts.PINNED, databasePinnedPosition); + operations.add(ContentProviderOperation.newUpdate(uri).withValues(values).build()); + } + if (operations.isEmpty()) { + // Nothing to update + return; + } + try { + appContext.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); + // TODO(calderwoodra): log + } catch (RemoteException | OperationApplicationException e) { + LogUtil.e( + "SpeedDialUiItemMutator.updatePinnedPosition", + "Exception thrown when pinning contacts", + e); + } + } + + /** + * Returns a new list with duo reachable channels inserted. Duo channels won't replace ViLTE + * channels. + */ + @MainThread + public ImmutableList insertDuoChannels( + Context context, ImmutableList speedDialUiItems) { + Assert.isMainThread(); + + Duo duo = DuoComponent.get(context).getDuo(); + int maxDuoSuggestions = MAX_DUO_SUGGESTIONS; + + ImmutableList.Builder newSpeedDialItemList = ImmutableList.builder(); + // for each existing item + for (SpeedDialUiItem item : speedDialUiItems) { + // If the item is a suggestion + if (!item.isStarred()) { + // And duo reachable, insert a duo suggestion + if (maxDuoSuggestions > 0 && duo.isReachable(context, item.defaultChannel().number())) { + maxDuoSuggestions--; + Channel defaultChannel = + item.defaultChannel().toBuilder().setTechnology(Channel.DUO).build(); + newSpeedDialItemList.add(item.toBuilder().setDefaultChannel(defaultChannel).build()); + } + // Insert the voice suggestion too + newSpeedDialItemList.add(item); + } else if (item.defaultChannel() == null) { + // If the contact is starred and doesn't have a default channel, insert duo channels + newSpeedDialItemList.add(insertDuoChannelsToStarredContact(context, item)); + } else { + // if starred and has a default channel, leave it as is, the user knows what they want. + newSpeedDialItemList.add(item); + } + } + return newSpeedDialItemList.build(); + } + + @MainThread + private SpeedDialUiItem insertDuoChannelsToStarredContact(Context context, SpeedDialUiItem item) { + Assert.isMainThread(); + Assert.checkArgument(item.isStarred()); + + // build a new list of channels + ImmutableList.Builder newChannelsList = ImmutableList.builder(); + Channel previousChannel = item.channels().get(0); + newChannelsList.add(previousChannel); + + for (int i = 1; i < item.channels().size(); i++) { + Channel currentChannel = item.channels().get(i); + // If the previous and current channel are voice channels, that means the previous number + // didn't have a video channel. + // If the previous number is duo reachable, insert a duo channel. + if (!previousChannel.isVideoTechnology() + && !currentChannel.isVideoTechnology() + && DuoComponent.get(context).getDuo().isReachable(context, previousChannel.number())) { + newChannelsList.add(previousChannel.toBuilder().setTechnology(Channel.DUO).build()); + } + newChannelsList.add(currentChannel); + previousChannel = currentChannel; + } + + // Check the last channel + if (!previousChannel.isVideoTechnology() + && DuoComponent.get(context).getDuo().isReachable(context, previousChannel.number())) { + newChannelsList.add(previousChannel.toBuilder().setTechnology(Channel.DUO).build()); + } + return item.toBuilder().setChannels(newChannelsList.build()).build(); + } + + private SpeedDialEntryDao getSpeedDialEntryDao() { + return new SpeedDialEntryDatabaseHelper(appContext); + } + + private boolean isPrimaryDisplayNameOrder() { + return contactsPreferences.getDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY; + } +} diff --git a/java/com/android/dialer/speeddial/loader/UiItemLoaderComponent.java b/java/com/android/dialer/speeddial/loader/UiItemLoaderComponent.java index 7d01b4380..852908409 100644 --- a/java/com/android/dialer/speeddial/loader/UiItemLoaderComponent.java +++ b/java/com/android/dialer/speeddial/loader/UiItemLoaderComponent.java @@ -24,7 +24,7 @@ import dagger.Subcomponent; @Subcomponent public abstract class UiItemLoaderComponent { - public abstract SpeedDialUiItemLoader speedDialUiItemLoader(); + public abstract SpeedDialUiItemMutator speedDialUiItemMutator(); public static UiItemLoaderComponent get(Context context) { return ((UiItemLoaderComponent.HasComponent) -- cgit v1.2.3