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.support.v4.os.BuildCompat; 33 import android.telecom.Call; 34 import android.telecom.PhoneAccount; 35 import android.telecom.VideoProfile; 36 import android.text.BidiFormatter; 37 import android.text.TextDirectionHeuristics; 38 import android.text.TextUtils; 39 import android.util.ArrayMap; 40 import com.android.contacts.common.ContactsUtils; 41 import com.android.contacts.common.compat.CallCompat; 42 import com.android.contacts.common.preference.ContactsPreferences; 43 import com.android.contacts.common.util.BitmapUtil; 44 import com.android.contacts.common.util.ContactDisplayUtils; 45 import com.android.dialer.notification.NotificationChannelId; 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 String NOTIFICATION_TAG = "EXTERNAL_CALL"; 63 64 private static final int NOTIFICATION_SUMMARY_ID = -1; 65 66 private final Context mContext; 67 private final ContactInfoCache mContactInfoCache; 68 private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>(); 69 private int mNextUniqueNotificationId; 70 private ContactsPreferences mContactsPreferences; 71 private boolean mShowingSummary; 72 73 /** Initializes a new instance of the external call notifier. */ ExternalCallNotifier( @onNull Context context, @NonNull ContactInfoCache contactInfoCache)74 public ExternalCallNotifier( 75 @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) { 76 mContext = context; 77 mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext); 78 mContactInfoCache = contactInfoCache; 79 } 80 81 /** 82 * Handles the addition of a new external call by showing a new notification. Triggered by {@link 83 * CallList#onCallAdded(android.telecom.Call)}. 84 */ 85 @Override onExternalCallAdded(android.telecom.Call call)86 public void onExternalCallAdded(android.telecom.Call call) { 87 Log.i(this, "onExternalCallAdded " + call); 88 if (mNotifications.containsKey(call)) { 89 throw new IllegalArgumentException(); 90 } 91 NotificationInfo info = new NotificationInfo(call, mNextUniqueNotificationId++); 92 mNotifications.put(call, info); 93 94 showNotifcation(info); 95 } 96 97 /** 98 * Handles the removal of an external call by hiding its associated notification. Triggered by 99 * {@link CallList#onCallRemoved(android.telecom.Call)}. 100 */ 101 @Override onExternalCallRemoved(android.telecom.Call call)102 public void onExternalCallRemoved(android.telecom.Call call) { 103 Log.i(this, "onExternalCallRemoved " + call); 104 105 dismissNotification(call); 106 } 107 108 /** Handles updates to an external call. */ 109 @Override onExternalCallUpdated(Call call)110 public void onExternalCallUpdated(Call call) { 111 if (!mNotifications.containsKey(call)) { 112 throw new IllegalArgumentException(); 113 } 114 postNotification(mNotifications.get(call)); 115 } 116 117 @Override onExternalCallPulled(Call call)118 public void onExternalCallPulled(Call call) { 119 // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved. 120 } 121 122 /** 123 * Initiates a call pull given a notification ID. 124 * 125 * @param notificationId The notification ID associated with the external call which is to be 126 * pulled. 127 */ 128 @TargetApi(VERSION_CODES.N_MR1) pullExternalCall(int notificationId)129 public void pullExternalCall(int notificationId) { 130 for (NotificationInfo info : mNotifications.values()) { 131 if (info.getNotificationId() == notificationId 132 && CallCompat.canPullExternalCall(info.getCall())) { 133 info.getCall().pullExternalCall(); 134 return; 135 } 136 } 137 } 138 139 /** 140 * Shows a notification for a new external call. Performs a contact cache lookup to find any 141 * associated photo and information for the call. 142 */ showNotifcation(final NotificationInfo info)143 private void showNotifcation(final NotificationInfo info) { 144 // We make a call to the contact info cache to query for supplemental data to what the 145 // call provides. This includes the contact name and photo. 146 // This callback will always get called immediately and synchronously with whatever data 147 // it has available, and may make a subsequent call later (same thread) if it had to 148 // call into the contacts provider for more data. 149 DialerCall dialerCall = 150 new DialerCall( 151 mContext, 152 new DialerCallDelegateStub(), 153 info.getCall(), 154 new LatencyReport(), 155 false /* registerCallback */); 156 157 mContactInfoCache.findInfo( 158 dialerCall, 159 false /* isIncoming */, 160 new ContactInfoCache.ContactInfoCacheCallback() { 161 @Override 162 public void onContactInfoComplete( 163 String callId, ContactInfoCache.ContactCacheEntry entry) { 164 165 // Ensure notification still exists as the external call could have been 166 // removed during async contact info lookup. 167 if (mNotifications.containsKey(info.getCall())) { 168 saveContactInfo(info, entry); 169 } 170 } 171 172 @Override 173 public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) { 174 175 // Ensure notification still exists as the external call could have been 176 // removed during async contact info lookup. 177 if (mNotifications.containsKey(info.getCall())) { 178 savePhoto(info, entry); 179 } 180 } 181 }); 182 } 183 184 /** Dismisses a notification for an external call. */ dismissNotification(Call call)185 private void dismissNotification(Call call) { 186 if (!mNotifications.containsKey(call)) { 187 throw new IllegalArgumentException(); 188 } 189 190 NotificationManager notificationManager = 191 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); 192 notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId()); 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_TAG, NOTIFICATION_SUMMARY_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_TAG); 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 if (BuildCompat.isAtLeastO()) { 255 builder.setChannelId(NotificationChannelId.DEFAULT); 256 } 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 if (BuildCompat.isAtLeastO()) { 290 publicBuilder.setChannelId(NotificationChannelId.DEFAULT); 291 } 292 293 builder.setPublicVersion(publicBuilder.build()); 294 Notification notification = builder.build(); 295 296 NotificationManager notificationManager = 297 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); 298 notificationManager.notify(NOTIFICATION_TAG, info.getNotificationId(), notification); 299 300 if (!mShowingSummary && mNotifications.size() > 1) { 301 // If the number of notifications shown is > 1, and we're not already showing a group summary, 302 // build one now. This will ensure the like notifications are grouped together. 303 304 Notification.Builder summary = new Notification.Builder(mContext); 305 // Set notification as ongoing since calls are long-running versus a point-in-time notice. 306 summary.setOngoing(true); 307 // Make the notification prioritized over the other normal notifications. 308 summary.setPriority(Notification.PRIORITY_HIGH); 309 summary.setGroup(NOTIFICATION_TAG); 310 summary.setGroupSummary(true); 311 summary.setSmallIcon(R.drawable.quantum_ic_call_white_24); 312 if (BuildCompat.isAtLeastO()) { 313 summary.setChannelId(NotificationChannelId.DEFAULT); 314 } 315 notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_ID, summary.build()); 316 mShowingSummary = true; 317 } 318 } 319 320 /** 321 * Finds a large icon to display in a notification for a call. For conference calls, a conference 322 * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar 323 * is used. 324 * 325 * @param context The context. 326 * @param contactInfo The contact cache info. 327 * @param call The call. 328 * @return The large icon to use for the notification. 329 */ getLargeIconToDisplay( Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)330 private @Nullable Bitmap getLargeIconToDisplay( 331 Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) { 332 333 Bitmap largeIcon = null; 334 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE) 335 && !call.getDetails() 336 .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) { 337 338 largeIcon = 339 BitmapFactory.decodeResource( 340 context.getResources(), R.drawable.quantum_ic_group_vd_theme_24); 341 } 342 if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) { 343 largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap(); 344 } 345 return largeIcon; 346 } 347 348 /** 349 * Given a bitmap, returns a rounded version of the icon suitable for display in a notification. 350 * 351 * @param context The context. 352 * @param bitmap The bitmap to round. 353 * @return The rounded bitmap. 354 */ getRoundedIcon(Context context, @Nullable Bitmap bitmap)355 private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) { 356 if (bitmap == null) { 357 return null; 358 } 359 final int height = 360 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height); 361 final int width = 362 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width); 363 return BitmapUtil.getRoundedBitmap(bitmap, width, height); 364 } 365 366 /** 367 * Builds a notification content title for a call. If the call is a conference call, it is 368 * identified as such. Otherwise an attempt is made to show an associated contact name or phone 369 * number. 370 * 371 * @param context The context. 372 * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for 373 * contact names. 374 * @param contactInfo The contact info which was looked up in the contact cache. 375 * @param call The call to generate a title for. 376 * @return The content title. 377 */ getContentTitle( Context context, @Nullable ContactsPreferences contactsPreferences, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call)378 private @Nullable String getContentTitle( 379 Context context, 380 @Nullable ContactsPreferences contactsPreferences, 381 ContactInfoCache.ContactCacheEntry contactInfo, 382 android.telecom.Call call) { 383 384 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)) { 385 return CallerInfoUtils.getConferenceString( 386 context, 387 call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)); 388 } 389 390 String preferredName = 391 ContactDisplayUtils.getPreferredDisplayName( 392 contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences); 393 if (TextUtils.isEmpty(preferredName)) { 394 return TextUtils.isEmpty(contactInfo.number) 395 ? null 396 : BidiFormatter.getInstance() 397 .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR); 398 } 399 return preferredName; 400 } 401 402 /** 403 * Gets a "person reference" for a notification, used by the system to determine whether the 404 * notification should be allowed past notification interruption filters. 405 * 406 * @param contactInfo The contact info from cache. 407 * @param call The call. 408 * @return the person reference. 409 */ getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call)410 private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) { 411 412 String number = TelecomCallUtil.getNumber(call); 413 // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed. 414 // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid 415 // NotificationManager using it. 416 if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) { 417 return contactInfo.lookupUri.toString(); 418 } else if (!TextUtils.isEmpty(number)) { 419 return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString(); 420 } 421 return ""; 422 } 423 424 private static class DialerCallDelegateStub implements DialerCallDelegate { 425 426 @Override getDialerCallFromTelecomCall(Call telecomCall)427 public DialerCall getDialerCallFromTelecomCall(Call telecomCall) { 428 return null; 429 } 430 } 431 432 /** Represents a call and associated cached notification data. */ 433 private static class NotificationInfo { 434 435 @NonNull private final Call mCall; 436 private final int mNotificationId; 437 @Nullable private String mContentTitle; 438 @Nullable private Bitmap mLargeIcon; 439 @Nullable private String mPersonReference; 440 NotificationInfo(@onNull Call call, int notificationId)441 public NotificationInfo(@NonNull Call call, int notificationId) { 442 mCall = call; 443 mNotificationId = notificationId; 444 } 445 getCall()446 public Call getCall() { 447 return mCall; 448 } 449 getNotificationId()450 public int getNotificationId() { 451 return mNotificationId; 452 } 453 getContentTitle()454 public @Nullable String getContentTitle() { 455 return mContentTitle; 456 } 457 setContentTitle(@ullable String contentTitle)458 public void setContentTitle(@Nullable String contentTitle) { 459 mContentTitle = contentTitle; 460 } 461 getLargeIcon()462 public @Nullable Bitmap getLargeIcon() { 463 return mLargeIcon; 464 } 465 setLargeIcon(@ullable Bitmap largeIcon)466 public void setLargeIcon(@Nullable Bitmap largeIcon) { 467 mLargeIcon = largeIcon; 468 } 469 getPersonReference()470 public @Nullable String getPersonReference() { 471 return mPersonReference; 472 } 473 setPersonReference(@ullable String personReference)474 public void setPersonReference(@Nullable String personReference) { 475 mPersonReference = personReference; 476 } 477 } 478 } 479