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