diff options
Diffstat (limited to 'java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java')
-rw-r--r-- | java/com/android/dialer/speeddial/loader/SpeedDialUiItemMutator.java | 597 |
1 files changed, 597 insertions, 0 deletions
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() + * <ol> + * <li>Retrieve the list of {@link SpeedDialEntry} from {@link SpeedDialEntryDatabaseHelper}. + * <li>Build a list of {@link SpeedDialUiItem} based on {@link SpeedDialEntry#lookupKey()} in + * {@link Phone#CONTENT_URI}. + * <li>Remove any {@link SpeedDialEntry} that is no longer starred or whose contact was + * deleted. + * <li>Update each {@link SpeedDialEntry} contact id, lookup key and channel. + * <li>Build a list of {@link SpeedDialUiItem} from {@link Contacts#STREQUENT_PHONE_ONLY}. + * <li>If any starred contacts in that list aren't in the {@link + * SpeedDialEntryDatabaseHelper}, insert them now. + * <li>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}. + * </ol> + */ +@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<ImmutableList<SpeedDialUiItem>> 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<ImmutableList<SpeedDialUiItem>> starContact(Uri contactUri) { + return dialerFutureSerializer.submit( + () -> insertNewContactEntry(contactUri), backgroundExecutor); + } + + @WorkerThread + private ImmutableList<SpeedDialUiItem> 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<SpeedDialUiItem> 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<SpeedDialUiItem> speedDialUiItems = new ArrayList<>(); + + // We'll use these lists to update the SpeedDialEntry database + List<SpeedDialEntry> entriesToInsert = new ArrayList<>(); + List<SpeedDialEntry> entriesToUpdate = new ArrayList<>(); + List<Long> entriesToDelete = new ArrayList<>(); + + // Get all SpeedDialEntries and update their contact ids and lookupkeys. + List<SpeedDialEntry> entries = db.getAllEntries(); + entries = updateContactIdsAndLookupKeys(entries); + + // Build SpeedDialUiItems from our updated entries. + Map<SpeedDialEntry, SpeedDialUiItem> 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<SpeedDialUiItem> 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<SpeedDialEntry, Long> 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<SpeedDialUiItem> speedDialUiItemsWithUpdatedIds( + List<SpeedDialUiItem> speedDialUiItems, + ImmutableMap<SpeedDialEntry, Long> insertedEntriesToIdsMap) { + if (insertedEntriesToIdsMap.isEmpty()) { + // There were no newly inserted entries, so all entries ids are set already. + return ImmutableList.copyOf(speedDialUiItems); + } + + ImmutableList.Builder<SpeedDialUiItem> 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. + * + * <p>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<SpeedDialEntry> updateContactIdsAndLookupKeys(List<SpeedDialEntry> entries) { + Assert.isWorkerThread(); + List<SpeedDialEntry> 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<SpeedDialEntry, SpeedDialUiItem> getSpeedDialUiItemsFromEntries( + List<SpeedDialEntry> entries) { + Assert.isWorkerThread(); + // Fetch the contact ids from the SpeedDialEntries + Set<String> 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<SpeedDialEntry, SpeedDialUiItem> 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<Channel> 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<SpeedDialUiItem> getStrequentContacts() { + Assert.isWorkerThread(); + Set<String> 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<SpeedDialUiItem> 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<SpeedDialUiItem> speedDialUiItems) { + Assert.isWorkerThread(); + if (speedDialUiItems == null || speedDialUiItems.isEmpty()) { + return; + } + + // Update the positions in the SpeedDialEntry database + ImmutableList.Builder<SpeedDialEntry> 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<SpeedDialUiItem> cp2SpeedDialUiItems = new ArrayList<>(); + Set<Long> contactIds = new ArraySet<>(); + for (SpeedDialUiItem item : speedDialUiItems) { + if (contactIds.add(item.contactId())) { + cp2SpeedDialUiItems.add(item); + } + } + + // Code copied from PhoneFavoritesTileAdapter#handleDrop + ArrayList<ContentProviderOperation> 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<SpeedDialUiItem> insertDuoChannels( + Context context, ImmutableList<SpeedDialUiItem> speedDialUiItems) { + Assert.isMainThread(); + + Duo duo = DuoComponent.get(context).getDuo(); + int maxDuoSuggestions = MAX_DUO_SUGGESTIONS; + + ImmutableList.Builder<SpeedDialUiItem> 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<Channel> 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; + } +} |