/* * 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.res.Resources; import android.database.Cursor; import android.os.Trace; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Data; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.ArraySet; import com.android.dialer.common.Assert; import com.android.dialer.glidephotomanager.PhotoInfo; 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; import java.util.Objects; import java.util.Set; /** * POJO representation of each speed dial list element. * *

Contains all data needed for the UI so that the UI never needs do additional contact queries. * *

Differs from {@link SpeedDialEntry} in that entries are specific to favorited/starred contacts * and {@link SpeedDialUiItem}s can be both favorites and suggested contacts. */ @AutoValue public abstract class SpeedDialUiItem { public static final int LOOKUP_KEY = 0; public static final int CONTACT_ID = 1; public static final int DISPLAY_NAME = 2; public static final int STARRED = 3; public static final int NUMBER = 4; public static final int TYPE = 5; public static final int LABEL = 6; public static final int PHOTO_ID = 7; public static final int PHOTO_URI = 8; public static final int CARRIER_PRESENCE = 9; private static final String[] PHONE_PROJECTION = { Phone.LOOKUP_KEY, Phone.CONTACT_ID, Phone.DISPLAY_NAME, Phone.STARRED, Phone.NUMBER, Phone.TYPE, Phone.LABEL, Phone.PHOTO_ID, Phone.PHOTO_URI, Phone.CARRIER_PRESENCE }; private static final String[] PHONE_PROJECTION_ALTERNATIVE = { Phone.LOOKUP_KEY, Phone.CONTACT_ID, Phone.DISPLAY_NAME_ALTERNATIVE, Phone.STARRED, Phone.NUMBER, Phone.TYPE, Phone.LABEL, Phone.PHOTO_ID, Phone.PHOTO_URI, Phone.CARRIER_PRESENCE }; public static String[] getPhoneProjection(boolean primaryDisplayOrder) { return primaryDisplayOrder ? PHONE_PROJECTION : PHONE_PROJECTION_ALTERNATIVE; } public static Builder builder() { return new AutoValue_SpeedDialUiItem.Builder() .setChannels(ImmutableList.of()) .setPinnedPosition(Optional.absent()); } /** * Convert a cursor with projection {@link #getPhoneProjection(boolean)} into a {@link * SpeedDialUiItem}. * *

This cursor is structured such that contacts are grouped by contact id and lookup key and * each row that shares the same contact id and lookup key represents a phone number that belongs * to a single contact. * *

If the cursor started at row X, this method will advance to row Y s.t. rows X, X + 1, ... Y * - 1 all belong to the same contact (that is, share the same contact id and lookup key). */ public static SpeedDialUiItem fromCursor( Resources resources, Cursor cursor, boolean isImsEnabled) { Trace.beginSection("fromCursor"); Assert.checkArgument(cursor != null); Assert.checkArgument(cursor.getCount() != 0); String lookupKey = cursor.getString(LOOKUP_KEY); SpeedDialUiItem.Builder builder = SpeedDialUiItem.builder() .setLookupKey(lookupKey) .setContactId(cursor.getLong(CONTACT_ID)) // TODO(a bug): handle last name first preference .setName(cursor.getString(DISPLAY_NAME)) .setIsStarred(cursor.getInt(STARRED) == 1) .setPhotoId(cursor.getLong(PHOTO_ID)) .setPhotoUri( TextUtils.isEmpty(cursor.getString(PHOTO_URI)) ? "" : cursor.getString(PHOTO_URI)); // While there are more rows and the lookup keys are the same, add a channel for each of the // contact's phone numbers. List channels = new ArrayList<>(); Set numbers = new ArraySet<>(); do { String number = cursor.getString(NUMBER); // TODO(78492722): consider using lib phone number to compare numbers if (!numbers.add(number)) { // Number is identical to an existing number, skip this number continue; } Channel channel = Channel.builder() .setNumber(number) .setPhoneType(cursor.getInt(TYPE)) .setLabel(getLabel(resources, cursor)) .setTechnology(Channel.VOICE) .build(); channels.add(channel); if (isImsEnabled && (cursor.getInt(CARRIER_PRESENCE) & Data.CARRIER_PRESENCE_VT_CAPABLE) == 1) { // Add another channel if the number is ViLTE reachable channels.add(channel.toBuilder().setTechnology(Channel.IMS_VIDEO).build()); } // TODO(a bug): add another channel for Duo (needs to happen on main thread) } while (cursor.moveToNext() && Objects.equals(lookupKey, cursor.getString(LOOKUP_KEY))); builder.setChannels(ImmutableList.copyOf(channels)); Trace.endSection(); return builder.build(); } private static String getLabel(Resources resources, Cursor cursor) { int numberType = cursor.getInt(TYPE); String numberLabel = cursor.getString(LABEL); // Returns empty label instead of "custom" if the custom label is empty. if (numberType == Phone.TYPE_CUSTOM && TextUtils.isEmpty(numberLabel)) { return ""; } return (String) Phone.getTypeLabel(resources, numberType, numberLabel); } public PhotoInfo getPhotoInfo() { return PhotoInfo.newBuilder() .setPhotoId(photoId()) .setPhotoUri(photoUri()) .setName(name()) .setIsVideo(defaultChannel() != null && defaultChannel().isVideoTechnology()) .setLookupUri(Contacts.getLookupUri(contactId(), lookupKey()).toString()) .build(); } public SpeedDialEntry buildSpeedDialEntry() { return SpeedDialEntry.builder() .setId(speedDialEntryId()) .setPinnedPosition(pinnedPosition()) .setLookupKey(lookupKey()) .setContactId(contactId()) .setDefaultChannel(defaultChannel()) .build(); } /** * Returns one of the following: * *

*/ @Nullable public Channel getDefaultVideoChannel() { if (defaultChannel() == null) { return null; } if (defaultChannel().isVideoTechnology()) { return defaultChannel(); } if (channels().size() == 1) { // If there is only a single channel, it can't be a video channel return null; } // At this point, the default channel is a *voice* channel and there are more than // one channel in total. // // Our defined assumptions about the channel list include that if a video channel // follows a voice channel, it has the same attributes as that voice channel // (see comments on method channels() for details). // // Therefore, if the default video channel exists, it must be the immediate successor // of the default channel in the list. // // Note that we don't have to check if the last channel in the list is the default // channel because even if it is, there will be no video channel under the assumption // above. for (int i = 0; i < channels().size() - 1; i++) { // Find the default channel if (Objects.equals(defaultChannel(), channels().get(i))) { // Our defined assumptions about the list of channels is that if a video channel follows a // voice channel, it has the same attributes as that voice channel. Channel channel = channels().get(i + 1); if (channel.isVideoTechnology()) { return channel; } // Since the default voice channel isn't video reachable, we can't video call this number return null; } } throw Assert.createIllegalStateFailException("channels() doesn't contain defaultChannel()."); } /** * Returns a voice channel if there is exactly one channel or the default channel is a voice * channel. */ @Nullable public Channel getDefaultVoiceChannel() { if (channels().size() == 1) { // If there is only a single channel, it must be a voice channel as per our defined // assumptions (detailed in comments on method channels()). return channels().get(0); } if (defaultChannel() == null) { return null; } if (!defaultChannel().isVideoTechnology()) { return defaultChannel(); } // Default channel is a video channel, so find it's corresponding voice channel by number since // unreachable channels may not be in the list for (Channel currentChannel : channels()) { if (currentChannel.number().equals(defaultChannel().number()) && currentChannel.technology() == Channel.VOICE) { return currentChannel; } } return null; } /** * The id of the corresponding SpeedDialEntry. Null if the UI item does not have an entry, for * example suggested contacts (isStarred() will also be false) * * @see SpeedDialEntry#id() */ @Nullable public abstract Long speedDialEntryId(); /** @see SpeedDialEntry#pinnedPosition() */ public abstract Optional pinnedPosition(); /** @see android.provider.ContactsContract.Contacts#DISPLAY_NAME */ public abstract String name(); /** @see android.provider.ContactsContract.Contacts#_ID */ public abstract long contactId(); /** @see android.provider.ContactsContract.Contacts#LOOKUP_KEY */ public abstract String lookupKey(); /** @see android.provider.ContactsContract.Contacts#STARRED */ public abstract boolean isStarred(); /** @see Phone#PHOTO_ID */ public abstract long photoId(); /** @see Phone#PHOTO_URI */ public abstract String photoUri(); /** * Returns a list of channels available. A Duo channel is included iff it is reachable. Since a * contact can have multiple phone numbers and each number can have multiple technologies, * enumerate each one here so that the user can choose the correct one. Each channel here * represents a row in the {@link com.android.dialer.speeddial.DisambigDialog}. * *

These channels have a few very strictly enforced assumption that are used heavily throughout * the codebase. Those assumption are that: * *

    *
  1. Each of the contact's numbers are voice reachable. So if a channel has it's technology * set to anything other than {@link Channel#VOICE}, there is gaurenteed to be another * channel with the exact same attributes, but technology will be {@link Channel#VOICE}. *
  2. For each of the contact's phone numbers, there will be a voice channel, then the next * channel will either be the same phone number but a video channel, or a new number. *
* * For example: Say a contact has two phone numbers (A & B) and A is duo reachable. Then you can * assume the list of channels will be ordered as either {A_voice, A_duo, B_voice} or {B_voice, * A_voice, A_duo}. * * @see com.android.dialer.speeddial.database.SpeedDialEntry.Channel */ public abstract ImmutableList channels(); /** * Will be null when the user hasn't chosen a default yet. Note that a default channel may not be * in the list returned by {@link #channels()}. This is because that list does not contain an * unreachable Duo channel. When the default channel is a Duo channel and it becomes unreachable, * it will remain as the default channel but disappear in the list returned by {@link * #channels()}. * * @see com.android.dialer.speeddial.database.SpeedDialEntry#defaultChannel() */ public abstract @Nullable Channel defaultChannel(); public abstract Builder toBuilder(); /** Builder class for speed dial contact. */ @AutoValue.Builder public abstract static class Builder { /** 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); public abstract Builder setLookupKey(String lookupKey); public abstract Builder setIsStarred(boolean isStarred); public abstract Builder setPhotoId(long photoId); public abstract Builder setPhotoUri(String photoUri); public abstract Builder setChannels(ImmutableList channels); /** Set to null if the user hasn't chosen a default or the channel no longer exists. */ public abstract Builder setDefaultChannel(@Nullable Channel defaultChannel); public abstract SpeedDialUiItem build(); } }