blob: 6ec94a631050e251afda5858a2a7a7016bf7ad01 [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
341 largeIcon = BitmapFactory.decodeResource(context.getResources(), R.drawable.img_conference);
342 }
343 if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
344 largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
345 }
346 return largeIcon;
347 }
348
349 /**
350 * Given a bitmap, returns a rounded version of the icon suitable for display in a notification.
351 *
352 * @param context The context.
353 * @param bitmap The bitmap to round.
354 * @return The rounded bitmap.
355 */
356 private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) {
357 if (bitmap == null) {
358 return null;
359 }
360 final int height =
361 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height);
362 final int width =
363 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width);
364 return BitmapUtil.getRoundedBitmap(bitmap, width, height);
365 }
366
367 /**
368 * Builds a notification content title for a call. If the call is a conference call, it is
369 * identified as such. Otherwise an attempt is made to show an associated contact name or phone
370 * number.
371 *
372 * @param context The context.
373 * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for
374 * contact names.
375 * @param contactInfo The contact info which was looked up in the contact cache.
376 * @param call The call to generate a title for.
377 * @return The content title.
378 */
379 private @Nullable String getContentTitle(
380 Context context,
381 @Nullable ContactsPreferences contactsPreferences,
382 ContactInfoCache.ContactCacheEntry contactInfo,
383 android.telecom.Call call) {
384
385 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
386 && !call.getDetails()
387 .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
388
389 return context.getResources().getString(R.string.conference_call_name);
390 }
391
392 String preferredName =
393 ContactDisplayUtils.getPreferredDisplayName(
394 contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
395 if (TextUtils.isEmpty(preferredName)) {
396 return TextUtils.isEmpty(contactInfo.number)
397 ? null
398 : BidiFormatter.getInstance()
399 .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
400 }
401 return preferredName;
402 }
403
404 /**
405 * Gets a "person reference" for a notification, used by the system to determine whether the
406 * notification should be allowed past notification interruption filters.
407 *
408 * @param contactInfo The contact info from cache.
409 * @param call The call.
410 * @return the person reference.
411 */
412 private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
413
414 String number = TelecomCallUtil.getNumber(call);
415 // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
416 // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
417 // NotificationManager using it.
418 if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
419 return contactInfo.lookupUri.toString();
420 } else if (!TextUtils.isEmpty(number)) {
421 return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
422 }
423 return "";
424 }
425
426 private static class DialerCallDelegateStub implements DialerCallDelegate {
427
428 @Override
429 public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
430 return null;
431 }
432 }
433
434 /** Represents a call and associated cached notification data. */
435 private static class NotificationInfo {
436
437 @NonNull private final Call mCall;
438 private final int mNotificationId;
439 @Nullable private String mContentTitle;
440 @Nullable private Bitmap mLargeIcon;
441 @Nullable private String mPersonReference;
442
443 public NotificationInfo(@NonNull Call call, int notificationId) {
444 mCall = call;
445 mNotificationId = notificationId;
446 }
447
448 public Call getCall() {
449 return mCall;
450 }
451
452 public int getNotificationId() {
453 return mNotificationId;
454 }
455
456 public @Nullable String getContentTitle() {
457 return mContentTitle;
458 }
459
460 public void setContentTitle(@Nullable String contentTitle) {
461 mContentTitle = contentTitle;
462 }
463
464 public @Nullable Bitmap getLargeIcon() {
465 return mLargeIcon;
466 }
467
468 public void setLargeIcon(@Nullable Bitmap largeIcon) {
469 mLargeIcon = largeIcon;
470 }
471
472 public @Nullable String getPersonReference() {
473 return mPersonReference;
474 }
475
476 public void setPersonReference(@Nullable String personReference) {
477 mPersonReference = personReference;
478 }
479 }
480}