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. */ 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 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 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 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 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) 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 */ 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. */ 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 */ 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 */ 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}. */ 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 */ 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 */ 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 */ 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 */ 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 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 445 public NotificationInfo(@NonNull Call call, int notificationId) { 446 mCall = call; 447 mNotificationId = notificationId; 448 } 449 450 public Call getCall() { 451 return mCall; 452 } 453 454 public int getNotificationId() { 455 return mNotificationId; 456 } 457 458 public @Nullable String getContentTitle() { 459 return mContentTitle; 460 } 461 462 public void setContentTitle(@Nullable String contentTitle) { 463 mContentTitle = contentTitle; 464 } 465 466 public @Nullable Bitmap getLargeIcon() { 467 return mLargeIcon; 468 } 469 470 public void setLargeIcon(@Nullable Bitmap largeIcon) { 471 mLargeIcon = largeIcon; 472 } 473 474 public @Nullable String getPersonReference() { 475 return mPersonReference; 476 } 477 478 public void setPersonReference(@Nullable String personReference) { 479 mPersonReference = personReference; 480 } 481 } 482 } 483