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