Home | History | Annotate | Download | only in incallui
      1 /*
      2  * Copyright (C) 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License
     15  */
     16 
     17 package com.android.incallui;
     18 
     19 import android.annotation.TargetApi;
     20 import android.app.Notification;
     21 import android.app.NotificationManager;
     22 import android.app.PendingIntent;
     23 import android.content.Context;
     24 import android.content.Intent;
     25 import android.graphics.Bitmap;
     26 import android.graphics.BitmapFactory;
     27 import android.graphics.drawable.BitmapDrawable;
     28 import android.net.Uri;
     29 import android.os.Build.VERSION_CODES;
     30 import android.support.annotation.NonNull;
     31 import android.support.annotation.Nullable;
     32 import android.telecom.Call;
     33 import android.telecom.PhoneAccount;
     34 import android.telecom.VideoProfile;
     35 import android.text.BidiFormatter;
     36 import android.text.TextDirectionHeuristics;
     37 import android.text.TextUtils;
     38 import android.util.ArrayMap;
     39 import com.android.contacts.common.ContactsUtils;
     40 import com.android.contacts.common.compat.CallCompat;
     41 import com.android.contacts.common.preference.ContactsPreferences;
     42 import com.android.contacts.common.util.BitmapUtil;
     43 import com.android.contacts.common.util.ContactDisplayUtils;
     44 import com.android.dialer.notification.NotificationChannelManager;
     45 import com.android.dialer.notification.NotificationChannelManager.Channel;
     46 import com.android.incallui.call.DialerCall;
     47 import com.android.incallui.call.DialerCallDelegate;
     48 import com.android.incallui.call.ExternalCallList;
     49 import com.android.incallui.latencyreport.LatencyReport;
     50 import com.android.incallui.util.TelecomCallUtil;
     51 import java.util.Map;
     52 
     53 /**
     54  * Handles the display of notifications for "external calls".
     55  *
     56  * <p>External calls are a representation of a call which is in progress on the user's other device
     57  * (e.g. another phone, or a watch).
     58  */
     59 public class ExternalCallNotifier implements ExternalCallList.ExternalCallListener {
     60 
     61   /** Tag used with the notification manager to uniquely identify external call notifications. */
     62   private static final int NOTIFICATION_ID = R.id.notification_external_call;
     63 
     64   private static final String NOTIFICATION_GROUP = "ExternalCallNotifier";
     65   private final Context mContext;
     66   private final ContactInfoCache mContactInfoCache;
     67   private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>();
     68   private int mNextUniqueNotificationId;
     69   private ContactsPreferences mContactsPreferences;
     70   private boolean mShowingSummary;
     71 
     72   /** Initializes a new instance of the external call notifier. */
     73   public ExternalCallNotifier(
     74       @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) {
     75     mContext = context;
     76     mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext);
     77     mContactInfoCache = contactInfoCache;
     78   }
     79 
     80   /**
     81    * Handles the addition of a new external call by showing a new notification. Triggered by {@link
     82    * CallList#onCallAdded(android.telecom.Call)}.
     83    */
     84   @Override
     85   public void onExternalCallAdded(android.telecom.Call call) {
     86     Log.i(this, "onExternalCallAdded " + call);
     87     if (mNotifications.containsKey(call)) {
     88       throw new IllegalArgumentException();
     89     }
     90     NotificationInfo info = new NotificationInfo(call, mNextUniqueNotificationId++);
     91     mNotifications.put(call, info);
     92 
     93     showNotifcation(info);
     94   }
     95 
     96   /**
     97    * Handles the removal of an external call by hiding its associated notification. Triggered by
     98    * {@link CallList#onCallRemoved(android.telecom.Call)}.
     99    */
    100   @Override
    101   public void onExternalCallRemoved(android.telecom.Call call) {
    102     Log.i(this, "onExternalCallRemoved " + call);
    103 
    104     dismissNotification(call);
    105   }
    106 
    107   /** Handles updates to an external call. */
    108   @Override
    109   public void onExternalCallUpdated(Call call) {
    110     if (!mNotifications.containsKey(call)) {
    111       throw new IllegalArgumentException();
    112     }
    113     postNotification(mNotifications.get(call));
    114   }
    115 
    116   @Override
    117   public void onExternalCallPulled(Call call) {
    118     // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved.
    119   }
    120 
    121   /**
    122    * Initiates a call pull given a notification ID.
    123    *
    124    * @param notificationId The notification ID associated with the external call which is to be
    125    *     pulled.
    126    */
    127   @TargetApi(VERSION_CODES.N_MR1)
    128   public void pullExternalCall(int notificationId) {
    129     for (NotificationInfo info : mNotifications.values()) {
    130       if (info.getNotificationId() == notificationId
    131           && CallCompat.canPullExternalCall(info.getCall())) {
    132         info.getCall().pullExternalCall();
    133         return;
    134       }
    135     }
    136   }
    137 
    138   /**
    139    * Shows a notification for a new external call. Performs a contact cache lookup to find any
    140    * associated photo and information for the call.
    141    */
    142   private void showNotifcation(final NotificationInfo info) {
    143     // We make a call to the contact info cache to query for supplemental data to what the
    144     // call provides.  This includes the contact name and photo.
    145     // This callback will always get called immediately and synchronously with whatever data
    146     // it has available, and may make a subsequent call later (same thread) if it had to
    147     // call into the contacts provider for more data.
    148     DialerCall dialerCall =
    149         new DialerCall(
    150             mContext,
    151             new DialerCallDelegateStub(),
    152             info.getCall(),
    153             new LatencyReport(),
    154             false /* registerCallback */);
    155 
    156     mContactInfoCache.findInfo(
    157         dialerCall,
    158         false /* isIncoming */,
    159         new ContactInfoCache.ContactInfoCacheCallback() {
    160           @Override
    161           public void onContactInfoComplete(
    162               String callId, ContactInfoCache.ContactCacheEntry entry) {
    163 
    164             // Ensure notification still exists as the external call could have been
    165             // removed during async contact info lookup.
    166             if (mNotifications.containsKey(info.getCall())) {
    167               saveContactInfo(info, entry);
    168             }
    169           }
    170 
    171           @Override
    172           public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) {
    173 
    174             // Ensure notification still exists as the external call could have been
    175             // removed during async contact info lookup.
    176             if (mNotifications.containsKey(info.getCall())) {
    177               savePhoto(info, entry);
    178             }
    179           }
    180         });
    181   }
    182 
    183   /** Dismisses a notification for an external call. */
    184   private void dismissNotification(Call call) {
    185     if (!mNotifications.containsKey(call)) {
    186       throw new IllegalArgumentException();
    187     }
    188 
    189     NotificationManager notificationManager =
    190         (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
    191     notificationManager.cancel(
    192         String.valueOf(mNotifications.get(call).getNotificationId()), NOTIFICATION_ID);
    193 
    194     mNotifications.remove(call);
    195 
    196     if (mShowingSummary && mNotifications.size() <= 1) {
    197       // Where a summary notification is showing and there is now not enough notifications to
    198       // necessitate a summary, cancel the summary.
    199       notificationManager.cancel(NOTIFICATION_GROUP, NOTIFICATION_ID);
    200       mShowingSummary = false;
    201 
    202       // If there is still a single call requiring a notification, re-post the notification as a
    203       // standalone notification without a summary notification.
    204       if (mNotifications.size() == 1) {
    205         postNotification(mNotifications.values().iterator().next());
    206       }
    207     }
    208   }
    209 
    210   /**
    211    * Attempts to build a large icon to use for the notification based on the contact info and post
    212    * the updated notification to the notification manager.
    213    */
    214   private void savePhoto(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) {
    215     Bitmap largeIcon = getLargeIconToDisplay(mContext, entry, info.getCall());
    216     if (largeIcon != null) {
    217       largeIcon = getRoundedIcon(mContext, largeIcon);
    218     }
    219     info.setLargeIcon(largeIcon);
    220     postNotification(info);
    221   }
    222 
    223   /**
    224    * Builds and stores the contact information the notification will display and posts the updated
    225    * notification to the notification manager.
    226    */
    227   private void saveContactInfo(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) {
    228     info.setContentTitle(getContentTitle(mContext, mContactsPreferences, entry, info.getCall()));
    229     info.setPersonReference(getPersonReference(entry, info.getCall()));
    230     postNotification(info);
    231   }
    232 
    233   /** Rebuild an existing or show a new notification given {@link NotificationInfo}. */
    234   private void postNotification(NotificationInfo info) {
    235     Notification.Builder builder = new Notification.Builder(mContext);
    236     // Set notification as ongoing since calls are long-running versus a point-in-time notice.
    237     builder.setOngoing(true);
    238     // Make the notification prioritized over the other normal notifications.
    239     builder.setPriority(Notification.PRIORITY_HIGH);
    240     builder.setGroup(NOTIFICATION_GROUP);
    241 
    242     boolean isVideoCall = VideoProfile.isVideo(info.getCall().getDetails().getVideoState());
    243     // Set the content ("Ongoing call on another device")
    244     builder.setContentText(
    245         mContext.getString(
    246             isVideoCall
    247                 ? R.string.notification_external_video_call
    248                 : R.string.notification_external_call));
    249     builder.setSmallIcon(R.drawable.quantum_ic_call_white_24);
    250     builder.setContentTitle(info.getContentTitle());
    251     builder.setLargeIcon(info.getLargeIcon());
    252     builder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color));
    253     builder.addPerson(info.getPersonReference());
    254 
    255     NotificationChannelManager.applyChannel(
    256         builder, mContext, Channel.EXTERNAL_CALL, info.getCall().getDetails().getAccountHandle());
    257 
    258     // Where the external call supports being transferred to the local device, add an action
    259     // to the notification to initiate the call pull process.
    260     if (CallCompat.canPullExternalCall(info.getCall())) {
    261 
    262       Intent intent =
    263           new Intent(
    264               NotificationBroadcastReceiver.ACTION_PULL_EXTERNAL_CALL,
    265               null,
    266               mContext,
    267               NotificationBroadcastReceiver.class);
    268       intent.putExtra(
    269           NotificationBroadcastReceiver.EXTRA_NOTIFICATION_ID, info.getNotificationId());
    270       builder.addAction(
    271           new Notification.Action.Builder(
    272                   R.drawable.quantum_ic_call_white_24,
    273                   mContext.getString(
    274                       isVideoCall
    275                           ? R.string.notification_take_video_call
    276                           : R.string.notification_take_call),
    277                   PendingIntent.getBroadcast(mContext, info.getNotificationId(), intent, 0))
    278               .build());
    279     }
    280 
    281     /**
    282      * This builder is used for the notification shown when the device is locked and the user has
    283      * set their notification settings to 'hide sensitive content' {@see
    284      * Notification.Builder#setPublicVersion}.
    285      */
    286     Notification.Builder publicBuilder = new Notification.Builder(mContext);
    287     publicBuilder.setSmallIcon(R.drawable.quantum_ic_call_white_24);
    288     publicBuilder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color));
    289 
    290     NotificationChannelManager.applyChannel(
    291         publicBuilder,
    292         mContext,
    293         Channel.EXTERNAL_CALL,
    294         info.getCall().getDetails().getAccountHandle());
    295 
    296     builder.setPublicVersion(publicBuilder.build());
    297     Notification notification = builder.build();
    298 
    299     NotificationManager notificationManager =
    300         (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
    301     notificationManager.notify(
    302         String.valueOf(info.getNotificationId()), NOTIFICATION_ID, notification);
    303 
    304     if (!mShowingSummary && mNotifications.size() > 1) {
    305       // If the number of notifications shown is > 1, and we're not already showing a group summary,
    306       // build one now.  This will ensure the like notifications are grouped together.
    307 
    308       Notification.Builder summary = new Notification.Builder(mContext);
    309       // Set notification as ongoing since calls are long-running versus a point-in-time notice.
    310       summary.setOngoing(true);
    311       // Make the notification prioritized over the other normal notifications.
    312       summary.setPriority(Notification.PRIORITY_HIGH);
    313       summary.setGroup(NOTIFICATION_GROUP);
    314       summary.setGroupSummary(true);
    315       summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
    316       NotificationChannelManager.applyChannel(
    317           summary, mContext, Channel.EXTERNAL_CALL, info.getCall().getDetails().getAccountHandle());
    318       notificationManager.notify(NOTIFICATION_GROUP, NOTIFICATION_ID, summary.build());
    319       mShowingSummary = true;
    320     }
    321   }
    322 
    323   /**
    324    * Finds a large icon to display in a notification for a call. For conference calls, a conference
    325    * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar
    326    * is used.
    327    *
    328    * @param context The context.
    329    * @param contactInfo The contact cache info.
    330    * @param call The call.
    331    * @return The large icon to use for the notification.
    332    */
    333   private @Nullable Bitmap getLargeIconToDisplay(
    334       Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) {
    335 
    336     Bitmap largeIcon = null;
    337     if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
    338         && !call.getDetails()
    339             .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
    340 
    341       largeIcon =
    342           BitmapFactory.decodeResource(
    343               context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
    344     }
    345     if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
    346       largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
    347     }
    348     return largeIcon;
    349   }
    350 
    351   /**
    352    * Given a bitmap, returns a rounded version of the icon suitable for display in a notification.
    353    *
    354    * @param context The context.
    355    * @param bitmap The bitmap to round.
    356    * @return The rounded bitmap.
    357    */
    358   private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) {
    359     if (bitmap == null) {
    360       return null;
    361     }
    362     final int height =
    363         (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height);
    364     final int width =
    365         (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width);
    366     return BitmapUtil.getRoundedBitmap(bitmap, width, height);
    367   }
    368 
    369   /**
    370    * Builds a notification content title for a call. If the call is a conference call, it is
    371    * identified as such. Otherwise an attempt is made to show an associated contact name or phone
    372    * number.
    373    *
    374    * @param context The context.
    375    * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for
    376    *     contact names.
    377    * @param contactInfo The contact info which was looked up in the contact cache.
    378    * @param call The call to generate a title for.
    379    * @return The content title.
    380    */
    381   private @Nullable String getContentTitle(
    382       Context context,
    383       @Nullable ContactsPreferences contactsPreferences,
    384       ContactInfoCache.ContactCacheEntry contactInfo,
    385       android.telecom.Call call) {
    386 
    387     if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
    388         && !call.getDetails()
    389             .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
    390 
    391       return context.getResources().getString(R.string.conference_call_name);
    392     }
    393 
    394     String preferredName =
    395         ContactDisplayUtils.getPreferredDisplayName(
    396             contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
    397     if (TextUtils.isEmpty(preferredName)) {
    398       return TextUtils.isEmpty(contactInfo.number)
    399           ? null
    400           : BidiFormatter.getInstance()
    401               .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
    402     }
    403     return preferredName;
    404   }
    405 
    406   /**
    407    * Gets a "person reference" for a notification, used by the system to determine whether the
    408    * notification should be allowed past notification interruption filters.
    409    *
    410    * @param contactInfo The contact info from cache.
    411    * @param call The call.
    412    * @return the person reference.
    413    */
    414   private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
    415 
    416     String number = TelecomCallUtil.getNumber(call);
    417     // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
    418     // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
    419     // NotificationManager using it.
    420     if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
    421       return contactInfo.lookupUri.toString();
    422     } else if (!TextUtils.isEmpty(number)) {
    423       return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
    424     }
    425     return "";
    426   }
    427 
    428   private static class DialerCallDelegateStub implements DialerCallDelegate {
    429 
    430     @Override
    431     public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
    432       return null;
    433     }
    434   }
    435 
    436   /** Represents a call and associated cached notification data. */
    437   private static class NotificationInfo {
    438 
    439     @NonNull private final Call mCall;
    440     private final int mNotificationId;
    441     @Nullable private String mContentTitle;
    442     @Nullable private Bitmap mLargeIcon;
    443     @Nullable private String mPersonReference;
    444 
    445     public NotificationInfo(@NonNull Call call, int notificationId) {
    446       mCall = call;
    447       mNotificationId = notificationId;
    448     }
    449 
    450     public Call getCall() {
    451       return mCall;
    452     }
    453 
    454     public int getNotificationId() {
    455       return mNotificationId;
    456     }
    457 
    458     public @Nullable String getContentTitle() {
    459       return mContentTitle;
    460     }
    461 
    462     public void setContentTitle(@Nullable String contentTitle) {
    463       mContentTitle = contentTitle;
    464     }
    465 
    466     public @Nullable Bitmap getLargeIcon() {
    467       return mLargeIcon;
    468     }
    469 
    470     public void setLargeIcon(@Nullable Bitmap largeIcon) {
    471       mLargeIcon = largeIcon;
    472     }
    473 
    474     public @Nullable String getPersonReference() {
    475       return mPersonReference;
    476     }
    477 
    478     public void setPersonReference(@Nullable String personReference) {
    479       mPersonReference = personReference;
    480     }
    481   }
    482 }
    483