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.support.v4.os.BuildCompat;
     33 import android.telecom.Call;
     34 import android.telecom.PhoneAccount;
     35 import android.telecom.VideoProfile;
     36 import android.text.BidiFormatter;
     37 import android.text.TextDirectionHeuristics;
     38 import android.text.TextUtils;
     39 import android.util.ArrayMap;
     40 import com.android.contacts.common.ContactsUtils;
     41 import com.android.contacts.common.compat.CallCompat;
     42 import com.android.contacts.common.preference.ContactsPreferences;
     43 import com.android.contacts.common.util.BitmapUtil;
     44 import com.android.contacts.common.util.ContactDisplayUtils;
     45 import com.android.dialer.notification.NotificationChannelId;
     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 String NOTIFICATION_TAG = "EXTERNAL_CALL";
     63 
     64   private static final int NOTIFICATION_SUMMARY_ID = -1;
     65 
     66   private final Context mContext;
     67   private final ContactInfoCache mContactInfoCache;
     68   private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>();
     69   private int mNextUniqueNotificationId;
     70   private ContactsPreferences mContactsPreferences;
     71   private boolean mShowingSummary;
     72 
     73   /** Initializes a new instance of the external call notifier. */
     74   public ExternalCallNotifier(
     75       @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) {
     76     mContext = context;
     77     mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext);
     78     mContactInfoCache = contactInfoCache;
     79   }
     80 
     81   /**
     82    * Handles the addition of a new external call by showing a new notification. Triggered by {@link
     83    * CallList#onCallAdded(android.telecom.Call)}.
     84    */
     85   @Override
     86   public void onExternalCallAdded(android.telecom.Call call) {
     87     Log.i(this, "onExternalCallAdded " + call);
     88     if (mNotifications.containsKey(call)) {
     89       throw new IllegalArgumentException();
     90     }
     91     NotificationInfo info = new NotificationInfo(call, mNextUniqueNotificationId++);
     92     mNotifications.put(call, info);
     93 
     94     showNotifcation(info);
     95   }
     96 
     97   /**
     98    * Handles the removal of an external call by hiding its associated notification. Triggered by
     99    * {@link CallList#onCallRemoved(android.telecom.Call)}.
    100    */
    101   @Override
    102   public void onExternalCallRemoved(android.telecom.Call call) {
    103     Log.i(this, "onExternalCallRemoved " + call);
    104 
    105     dismissNotification(call);
    106   }
    107 
    108   /** Handles updates to an external call. */
    109   @Override
    110   public void onExternalCallUpdated(Call call) {
    111     if (!mNotifications.containsKey(call)) {
    112       throw new IllegalArgumentException();
    113     }
    114     postNotification(mNotifications.get(call));
    115   }
    116 
    117   @Override
    118   public void onExternalCallPulled(Call call) {
    119     // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved.
    120   }
    121 
    122   /**
    123    * Initiates a call pull given a notification ID.
    124    *
    125    * @param notificationId The notification ID associated with the external call which is to be
    126    *     pulled.
    127    */
    128   @TargetApi(VERSION_CODES.N_MR1)
    129   public void pullExternalCall(int notificationId) {
    130     for (NotificationInfo info : mNotifications.values()) {
    131       if (info.getNotificationId() == notificationId
    132           && CallCompat.canPullExternalCall(info.getCall())) {
    133         info.getCall().pullExternalCall();
    134         return;
    135       }
    136     }
    137   }
    138 
    139   /**
    140    * Shows a notification for a new external call. Performs a contact cache lookup to find any
    141    * associated photo and information for the call.
    142    */
    143   private void showNotifcation(final NotificationInfo info) {
    144     // We make a call to the contact info cache to query for supplemental data to what the
    145     // call provides.  This includes the contact name and photo.
    146     // This callback will always get called immediately and synchronously with whatever data
    147     // it has available, and may make a subsequent call later (same thread) if it had to
    148     // call into the contacts provider for more data.
    149     DialerCall dialerCall =
    150         new DialerCall(
    151             mContext,
    152             new DialerCallDelegateStub(),
    153             info.getCall(),
    154             new LatencyReport(),
    155             false /* registerCallback */);
    156 
    157     mContactInfoCache.findInfo(
    158         dialerCall,
    159         false /* isIncoming */,
    160         new ContactInfoCache.ContactInfoCacheCallback() {
    161           @Override
    162           public void onContactInfoComplete(
    163               String callId, ContactInfoCache.ContactCacheEntry entry) {
    164 
    165             // Ensure notification still exists as the external call could have been
    166             // removed during async contact info lookup.
    167             if (mNotifications.containsKey(info.getCall())) {
    168               saveContactInfo(info, entry);
    169             }
    170           }
    171 
    172           @Override
    173           public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) {
    174 
    175             // Ensure notification still exists as the external call could have been
    176             // removed during async contact info lookup.
    177             if (mNotifications.containsKey(info.getCall())) {
    178               savePhoto(info, entry);
    179             }
    180           }
    181         });
    182   }
    183 
    184   /** Dismisses a notification for an external call. */
    185   private void dismissNotification(Call call) {
    186     if (!mNotifications.containsKey(call)) {
    187       throw new IllegalArgumentException();
    188     }
    189 
    190     NotificationManager notificationManager =
    191         (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
    192     notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId());
    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_TAG, NOTIFICATION_SUMMARY_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_TAG);
    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     if (BuildCompat.isAtLeastO()) {
    255       builder.setChannelId(NotificationChannelId.DEFAULT);
    256     }
    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     if (BuildCompat.isAtLeastO()) {
    290       publicBuilder.setChannelId(NotificationChannelId.DEFAULT);
    291     }
    292 
    293     builder.setPublicVersion(publicBuilder.build());
    294     Notification notification = builder.build();
    295 
    296     NotificationManager notificationManager =
    297         (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
    298     notificationManager.notify(NOTIFICATION_TAG, info.getNotificationId(), notification);
    299 
    300     if (!mShowingSummary && mNotifications.size() > 1) {
    301       // If the number of notifications shown is > 1, and we're not already showing a group summary,
    302       // build one now.  This will ensure the like notifications are grouped together.
    303 
    304       Notification.Builder summary = new Notification.Builder(mContext);
    305       // Set notification as ongoing since calls are long-running versus a point-in-time notice.
    306       summary.setOngoing(true);
    307       // Make the notification prioritized over the other normal notifications.
    308       summary.setPriority(Notification.PRIORITY_HIGH);
    309       summary.setGroup(NOTIFICATION_TAG);
    310       summary.setGroupSummary(true);
    311       summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
    312       if (BuildCompat.isAtLeastO()) {
    313         summary.setChannelId(NotificationChannelId.DEFAULT);
    314       }
    315       notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_ID, summary.build());
    316       mShowingSummary = true;
    317     }
    318   }
    319 
    320   /**
    321    * Finds a large icon to display in a notification for a call. For conference calls, a conference
    322    * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar
    323    * is used.
    324    *
    325    * @param context The context.
    326    * @param contactInfo The contact cache info.
    327    * @param call The call.
    328    * @return The large icon to use for the notification.
    329    */
    330   private @Nullable Bitmap getLargeIconToDisplay(
    331       Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) {
    332 
    333     Bitmap largeIcon = null;
    334     if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
    335         && !call.getDetails()
    336             .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
    337 
    338       largeIcon =
    339           BitmapFactory.decodeResource(
    340               context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
    341     }
    342     if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
    343       largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
    344     }
    345     return largeIcon;
    346   }
    347 
    348   /**
    349    * Given a bitmap, returns a rounded version of the icon suitable for display in a notification.
    350    *
    351    * @param context The context.
    352    * @param bitmap The bitmap to round.
    353    * @return The rounded bitmap.
    354    */
    355   private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) {
    356     if (bitmap == null) {
    357       return null;
    358     }
    359     final int height =
    360         (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height);
    361     final int width =
    362         (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width);
    363     return BitmapUtil.getRoundedBitmap(bitmap, width, height);
    364   }
    365 
    366   /**
    367    * Builds a notification content title for a call. If the call is a conference call, it is
    368    * identified as such. Otherwise an attempt is made to show an associated contact name or phone
    369    * number.
    370    *
    371    * @param context The context.
    372    * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for
    373    *     contact names.
    374    * @param contactInfo The contact info which was looked up in the contact cache.
    375    * @param call The call to generate a title for.
    376    * @return The content title.
    377    */
    378   private @Nullable String getContentTitle(
    379       Context context,
    380       @Nullable ContactsPreferences contactsPreferences,
    381       ContactInfoCache.ContactCacheEntry contactInfo,
    382       android.telecom.Call call) {
    383 
    384     if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)) {
    385       return CallerInfoUtils.getConferenceString(
    386           context,
    387           call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE));
    388     }
    389 
    390     String preferredName =
    391         ContactDisplayUtils.getPreferredDisplayName(
    392             contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
    393     if (TextUtils.isEmpty(preferredName)) {
    394       return TextUtils.isEmpty(contactInfo.number)
    395           ? null
    396           : BidiFormatter.getInstance()
    397               .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
    398     }
    399     return preferredName;
    400   }
    401 
    402   /**
    403    * Gets a "person reference" for a notification, used by the system to determine whether the
    404    * notification should be allowed past notification interruption filters.
    405    *
    406    * @param contactInfo The contact info from cache.
    407    * @param call The call.
    408    * @return the person reference.
    409    */
    410   private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
    411 
    412     String number = TelecomCallUtil.getNumber(call);
    413     // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
    414     // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
    415     // NotificationManager using it.
    416     if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
    417       return contactInfo.lookupUri.toString();
    418     } else if (!TextUtils.isEmpty(number)) {
    419       return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
    420     }
    421     return "";
    422   }
    423 
    424   private static class DialerCallDelegateStub implements DialerCallDelegate {
    425 
    426     @Override
    427     public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
    428       return null;
    429     }
    430   }
    431 
    432   /** Represents a call and associated cached notification data. */
    433   private static class NotificationInfo {
    434 
    435     @NonNull private final Call mCall;
    436     private final int mNotificationId;
    437     @Nullable private String mContentTitle;
    438     @Nullable private Bitmap mLargeIcon;
    439     @Nullable private String mPersonReference;
    440 
    441     public NotificationInfo(@NonNull Call call, int notificationId) {
    442       mCall = call;
    443       mNotificationId = notificationId;
    444     }
    445 
    446     public Call getCall() {
    447       return mCall;
    448     }
    449 
    450     public int getNotificationId() {
    451       return mNotificationId;
    452     }
    453 
    454     public @Nullable String getContentTitle() {
    455       return mContentTitle;
    456     }
    457 
    458     public void setContentTitle(@Nullable String contentTitle) {
    459       mContentTitle = contentTitle;
    460     }
    461 
    462     public @Nullable Bitmap getLargeIcon() {
    463       return mLargeIcon;
    464     }
    465 
    466     public void setLargeIcon(@Nullable Bitmap largeIcon) {
    467       mLargeIcon = largeIcon;
    468     }
    469 
    470     public @Nullable String getPersonReference() {
    471       return mPersonReference;
    472     }
    473 
    474     public void setPersonReference(@Nullable String personReference) {
    475       mPersonReference = personReference;
    476     }
    477   }
    478 }
    479