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