/*
* 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.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.RemoteException;
import android.os.Trace;
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.dialer.common.Assert;
import com.android.dialer.common.LogUtil;
import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
import com.android.dialer.common.concurrent.DefaultFutureCallback;
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.contacts.ContactsComponent;
import com.android.dialer.contacts.displaypreference.ContactDisplayPreferences;
import com.android.dialer.contacts.displaypreference.ContactDisplayPreferences.DisplayOrder;
import com.android.dialer.contacts.hiresphoto.HighResolutionPhotoRequester;
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.android.dialer.util.CallUtil;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
/**
* Loads a list of {@link SpeedDialUiItem SpeedDialUiItems}.
*
* @see #loadSpeedDialUiItems()
*
> loadSpeedDialUiItems() {
return dialerFutureSerializer.submit(this::loadSpeedDialUiItemsInternal, backgroundExecutor);
}
/**
* Delete the SpeedDialUiItem.
*
* If the item is starred, it's entry will be removed from the SpeedDialEntry database.
* Additionally, if the contact only has one entry in the database, it will be unstarred.
*
*
If the item isn't starred, it's usage data will be deleted.
*
* @return the updated list of SpeedDialUiItems.
*/
public ListenableFuture> removeSpeedDialUiItem(
SpeedDialUiItem speedDialUiItem) {
return dialerFutureSerializer.submit(
() -> removeSpeedDialUiItemInternal(speedDialUiItem), backgroundExecutor);
}
@WorkerThread
private ImmutableList removeSpeedDialUiItemInternal(
SpeedDialUiItem speedDialUiItem) {
Assert.isWorkerThread();
Assert.checkArgument(speedDialUiItem.isStarred());
removeStarredSpeedDialUiItem(speedDialUiItem);
return loadSpeedDialUiItemsInternal();
}
/**
* Delete the SpeedDialEntry associated with the passed in SpeedDialUiItem. Additionally, if the
* entry being deleted is the only entry for that contact, unstar it in the cp2.
*/
@WorkerThread
private void removeStarredSpeedDialUiItem(SpeedDialUiItem speedDialUiItem) {
Assert.isWorkerThread();
Assert.checkArgument(speedDialUiItem.isStarred());
SpeedDialEntryDao db = getSpeedDialEntryDao();
ImmutableList entries = db.getAllEntries();
SpeedDialEntry entryToDelete = null;
int entriesForTheSameContact = 0;
for (SpeedDialEntry entry : entries) {
if (entry.contactId() == speedDialUiItem.contactId()) {
entriesForTheSameContact++;
}
if (Objects.equals(entry.id(), speedDialUiItem.speedDialEntryId())) {
Assert.checkArgument(entryToDelete == null);
entryToDelete = entry;
}
}
db.delete(ImmutableList.of(entryToDelete.id()));
if (entriesForTheSameContact == 1) {
unstarContact(speedDialUiItem);
}
}
@WorkerThread
private void unstarContact(SpeedDialUiItem speedDialUiItem) {
Assert.isWorkerThread();
ContentValues contentValues = new ContentValues();
contentValues.put(Phone.STARRED, 0);
appContext
.getContentResolver()
.update(
Contacts.CONTENT_URI,
contentValues,
Contacts._ID + " = ?",
new String[] {Long.toString(speedDialUiItem.contactId())});
}
/**
* 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();
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(
appContext.getResources(), cursor, CallUtil.isVideoEnabled(appContext));
// 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() {
Trace.beginSection("loadSpeedDialUiItemsInternal");
Assert.isWorkerThread();
Trace.beginSection("getAllEntries");
SpeedDialEntryDao db = getSpeedDialEntryDao();
Trace.endSection(); // getAllEntries
// 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
Trace.beginSection("updateOrDeleteEntries");
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);
}
Trace.endSection(); // updateOrDeleteEntries
// Get all starred contacts
List starredContacts = getStarredContacts();
// If it is starred and not already accounted for above, then insert into the SpeedDialEntry DB.
Trace.beginSection("addStarredContact");
for (SpeedDialUiItem contact : starredContacts) {
if (speedDialUiItems.stream().noneMatch(c -> c.contactId() == contact.contactId())) {
entriesToInsert.add(contact.buildSpeedDialEntry());
// These are our newly starred contacts
speedDialUiItems.add(contact);
}
}
Trace.endSection(); // addStarredContact
Trace.beginSection("insertUpdateAndDelete");
requestHighResolutionPhoto(entriesToInsert);
ImmutableMap insertedEntriesToIdsMap =
db.insertUpdateAndDelete(
ImmutableList.copyOf(entriesToInsert),
ImmutableList.copyOf(entriesToUpdate),
ImmutableList.copyOf(entriesToDelete));
Trace.endSection(); // insertUpdateAndDelete
Trace.endSection(); // loadSpeedDialUiItemsInternal
return speedDialUiItemsWithUpdatedIds(speedDialUiItems, insertedEntriesToIdsMap);
}
@WorkerThread
private void requestHighResolutionPhoto(List newEntries) {
ContactsComponent.get(appContext).highResolutionPhotoLoader();
for (SpeedDialEntry entry : newEntries) {
Uri uri;
uri = Contacts.getLookupUri(entry.contactId(), entry.lookupKey());
Futures.addCallback(
highResolutionPhotoRequester.request(uri),
new DefaultFutureCallback<>(),
MoreExecutors.directExecutor());
}
}
/**
* 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 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) {
Trace.beginSection("getSpeedDialUiItemsFromEntries");
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()) {
Trace.endSection();
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(
appContext.getResources(), cursor, CallUtil.isVideoEnabled(appContext));
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);
}
Trace.endSection();
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 getStarredContacts() {
Trace.beginSection("getStrequentContacts");
Assert.isWorkerThread();
Set contactIds = new ArraySet<>();
// Fetch the contact ids of all starred contacts
Uri strequentUri =
Contacts.CONTENT_STREQUENT_URI
.buildUpon()
.appendQueryParameter(ContactsContract.STREQUENT_PHONE_ONLY, "true")
.build();
Selection selection = Selection.column(Phone.STARRED).is("=", 1);
try (Cursor cursor =
appContext
.getContentResolver()
.query(
strequentUri,
new String[] {Phone.CONTACT_ID},
selection.getSelection(),
selection.getSelectionArgs(),
null)) {
if (cursor == null) {
LogUtil.e("SpeedDialUiItemMutator.getStarredContacts", "null cursor");
Trace.endSection();
return new ArrayList<>();
}
if (cursor.getCount() == 0) {
Trace.endSection();
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.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");
Trace.endSection();
return new ArrayList<>();
}
if (cursor.getCount() == 0) {
Trace.endSection();
return contacts;
}
for (cursor.moveToFirst(); !cursor.isAfterLast(); /* Iterate in the loop */ ) {
contacts.add(
SpeedDialUiItem.fromCursor(
appContext.getResources(), cursor, CallUtil.isVideoEnabled(appContext)));
}
Trace.endSection();
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();
ImmutableList.Builder newSpeedDialItemList = ImmutableList.builder();
// for each existing item
for (SpeedDialUiItem item : speedDialUiItems) {
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 contactDisplayPreferences.getDisplayOrder() == DisplayOrder.PRIMARY;
}
}