blob: 051392e2b24b44a73a883bbd008842e8a332b066 [file] [log] [blame]
Eric Erfanianccca3152017-02-22 16:32:36 -08001/*
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;
Eric Erfaniand5e47f62017-03-15 14:41:07 -070044import com.android.dialer.notification.NotificationChannelManager;
45import com.android.dialer.notification.NotificationChannelManager.Channel;
Eric Erfanianccca3152017-02-22 16:32:36 -080046import com.android.incallui.call.DialerCall;
47import com.android.incallui.call.DialerCallDelegate;
48import com.android.incallui.call.ExternalCallList;
49import com.android.incallui.latencyreport.LatencyReport;
50import com.android.incallui.util.TelecomCallUtil;
51import 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 */
59public class ExternalCallNotifier implements ExternalCallList.ExternalCallListener {
60
61 /** Tag used with the notification manager to uniquely identify external call notifications. */
Eric Erfaniand5e47f62017-03-15 14:41:07 -070062 private static final int NOTIFICATION_ID = R.id.notification_external_call;
Eric Erfanianccca3152017-02-22 16:32:36 -080063
Eric Erfaniand5e47f62017-03-15 14:41:07 -070064 private static final String NOTIFICATION_GROUP = "ExternalCallNotifier";
Eric Erfanianccca3152017-02-22 16:32:36 -080065 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);
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700191 notificationManager.cancel(
192 String.valueOf(mNotifications.get(call).getNotificationId()), NOTIFICATION_ID);
Eric Erfanianccca3152017-02-22 16:32:36 -0800193
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.
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700199 notificationManager.cancel(NOTIFICATION_GROUP, NOTIFICATION_ID);
Eric Erfanianccca3152017-02-22 16:32:36 -0800200 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);
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700240 builder.setGroup(NOTIFICATION_GROUP);
Eric Erfanianccca3152017-02-22 16:32:36 -0800241
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
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700255 NotificationChannelManager.applyChannel(
256 builder, mContext, Channel.EXTERNAL_CALL, info.getCall().getDetails().getAccountHandle());
257
Eric Erfanianccca3152017-02-22 16:32:36 -0800258 // 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
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700290 NotificationChannelManager.applyChannel(
291 publicBuilder,
292 mContext,
293 Channel.EXTERNAL_CALL,
294 info.getCall().getDetails().getAccountHandle());
295
Eric Erfanianccca3152017-02-22 16:32:36 -0800296 builder.setPublicVersion(publicBuilder.build());
297 Notification notification = builder.build();
298
299 NotificationManager notificationManager =
300 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700301 notificationManager.notify(
302 String.valueOf(info.getNotificationId()), NOTIFICATION_ID, notification);
Eric Erfanianccca3152017-02-22 16:32:36 -0800303
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);
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700313 summary.setGroup(NOTIFICATION_GROUP);
Eric Erfanianccca3152017-02-22 16:32:36 -0800314 summary.setGroupSummary(true);
315 summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700316 NotificationChannelManager.applyChannel(
317 summary, mContext, Channel.EXTERNAL_CALL, info.getCall().getDetails().getAccountHandle());
318 notificationManager.notify(NOTIFICATION_GROUP, NOTIFICATION_ID, summary.build());
Eric Erfanianccca3152017-02-22 16:32:36 -0800319 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
Eric Erfanian83b20212017-05-31 08:53:10 -0700341 largeIcon =
342 BitmapFactory.decodeResource(
343 context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
Eric Erfanianccca3152017-02-22 16:32:36 -0800344 }
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
Eric Erfanian91ce7d22017-06-05 13:35:02 -0700387 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)) {
388 return CallerInfoUtils.getConferenceString(
389 context,
390 call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE));
Eric Erfanianccca3152017-02-22 16:32:36 -0800391 }
392
393 String preferredName =
394 ContactDisplayUtils.getPreferredDisplayName(
395 contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
396 if (TextUtils.isEmpty(preferredName)) {
397 return TextUtils.isEmpty(contactInfo.number)
398 ? null
399 : BidiFormatter.getInstance()
400 .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
401 }
402 return preferredName;
403 }
404
405 /**
406 * Gets a "person reference" for a notification, used by the system to determine whether the
407 * notification should be allowed past notification interruption filters.
408 *
409 * @param contactInfo The contact info from cache.
410 * @param call The call.
411 * @return the person reference.
412 */
413 private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
414
415 String number = TelecomCallUtil.getNumber(call);
416 // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
417 // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
418 // NotificationManager using it.
419 if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
420 return contactInfo.lookupUri.toString();
421 } else if (!TextUtils.isEmpty(number)) {
422 return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
423 }
424 return "";
425 }
426
427 private static class DialerCallDelegateStub implements DialerCallDelegate {
428
429 @Override
430 public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
431 return null;
432 }
433 }
434
435 /** Represents a call and associated cached notification data. */
436 private static class NotificationInfo {
437
438 @NonNull private final Call mCall;
439 private final int mNotificationId;
440 @Nullable private String mContentTitle;
441 @Nullable private Bitmap mLargeIcon;
442 @Nullable private String mPersonReference;
443
444 public NotificationInfo(@NonNull Call call, int notificationId) {
445 mCall = call;
446 mNotificationId = notificationId;
447 }
448
449 public Call getCall() {
450 return mCall;
451 }
452
453 public int getNotificationId() {
454 return mNotificationId;
455 }
456
457 public @Nullable String getContentTitle() {
458 return mContentTitle;
459 }
460
461 public void setContentTitle(@Nullable String contentTitle) {
462 mContentTitle = contentTitle;
463 }
464
465 public @Nullable Bitmap getLargeIcon() {
466 return mLargeIcon;
467 }
468
469 public void setLargeIcon(@Nullable Bitmap largeIcon) {
470 mLargeIcon = largeIcon;
471 }
472
473 public @Nullable String getPersonReference() {
474 return mPersonReference;
475 }
476
477 public void setPersonReference(@Nullable String personReference) {
478 mPersonReference = personReference;
479 }
480 }
481}