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