• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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. */
ExternalCallNotifier( @onNull Context context, @NonNull ContactInfoCache contactInfoCache)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
onExternalCallAdded(android.telecom.Call call)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
onExternalCallRemoved(android.telecom.Call call)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
onExternalCallUpdated(Call call)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
onExternalCallPulled(Call call)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)
pullExternalCall(int notificationId)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    */
showNotifcation(final NotificationInfo info)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. */
dismissNotification(Call 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    */
savePhoto(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry)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    */
saveContactInfo(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry)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}. */
postNotification(NotificationInfo info)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    */
getLargeIconToDisplay( Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)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    */
getRoundedIcon(Context context, @Nullable Bitmap bitmap)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    */
getContentTitle( Context context, @Nullable ContactsPreferences contactsPreferences, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)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    */
getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call)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
getDialerCallFromTelecomCall(Call telecomCall)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 
NotificationInfo(@onNull Call call, int notificationId)445     public NotificationInfo(@NonNull Call call, int notificationId) {
446       mCall = call;
447       mNotificationId = notificationId;
448     }
449 
getCall()450     public Call getCall() {
451       return mCall;
452     }
453 
getNotificationId()454     public int getNotificationId() {
455       return mNotificationId;
456     }
457 
getContentTitle()458     public @Nullable String getContentTitle() {
459       return mContentTitle;
460     }
461 
setContentTitle(@ullable String contentTitle)462     public void setContentTitle(@Nullable String contentTitle) {
463       mContentTitle = contentTitle;
464     }
465 
getLargeIcon()466     public @Nullable Bitmap getLargeIcon() {
467       return mLargeIcon;
468     }
469 
setLargeIcon(@ullable Bitmap largeIcon)470     public void setLargeIcon(@Nullable Bitmap largeIcon) {
471       mLargeIcon = largeIcon;
472     }
473 
getPersonReference()474     public @Nullable String getPersonReference() {
475       return mPersonReference;
476     }
477 
setPersonReference(@ullable String personReference)478     public void setPersonReference(@Nullable String personReference) {
479       mPersonReference = personReference;
480     }
481   }
482 }
483