• 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.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. */
ExternalCallNotifier( @onNull Context context, @NonNull ContactInfoCache contactInfoCache)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
onExternalCallAdded(android.telecom.Call call)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
onExternalCallRemoved(android.telecom.Call call)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
onExternalCallUpdated(Call call)117   public void onExternalCallUpdated(Call call) {
118     Assert.checkArgument(notifications.containsKey(call));
119     postNotification(notifications.get(call));
120   }
121 
122   @Override
onExternalCallPulled(Call call)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)
pullExternalCall(int notificationId)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    */
showNotifcation(final NotificationInfo info)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. */
dismissNotification(Call 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    */
savePhoto(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry)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    */
saveContactInfo(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry)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}. */
postNotification(NotificationInfo info)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    */
getLargeIconToDisplay( Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)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    */
getRoundedIcon(Context context, @Nullable Bitmap bitmap)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    */
getContentTitle( Context context, @Nullable ContactsPreferences contactsPreferences, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)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    */
getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call)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
getDialerCallFromTelecomCall(Call telecomCall)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 
NotificationInfo(@onNull Call call, int notificationId)413     public NotificationInfo(@NonNull Call call, int notificationId) {
414       this.call = call;
415       this.notificationId = notificationId;
416     }
417 
getCall()418     public Call getCall() {
419       return call;
420     }
421 
getNotificationId()422     public int getNotificationId() {
423       return notificationId;
424     }
425 
getContentTitle()426     public @Nullable String getContentTitle() {
427       return contentTitle;
428     }
429 
setContentTitle(@ullable String contentTitle)430     public void setContentTitle(@Nullable String contentTitle) {
431       this.contentTitle = contentTitle;
432     }
433 
getLargeIcon()434     public @Nullable Bitmap getLargeIcon() {
435       return largeIcon;
436     }
437 
setLargeIcon(@ullable Bitmap largeIcon)438     public void setLargeIcon(@Nullable Bitmap largeIcon) {
439       this.largeIcon = largeIcon;
440     }
441 
getPersonReference()442     public @Nullable String getPersonReference() {
443       return personReference;
444     }
445 
setPersonReference(@ullable String personReference)446     public void setPersonReference(@Nullable String personReference) {
447       this.personReference = personReference;
448     }
449   }
450 
showGroupSummaryNotification(@onNull Context context)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