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