blob: f01a29458b15c9d6ed7ad50f9d23c43bf039de7b [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;
Eric Erfanianea7890c2017-06-19 12:40:59 -070032import android.support.v4.os.BuildCompat;
Eric Erfanianccca3152017-02-22 16:32:36 -080033import android.telecom.Call;
34import android.telecom.PhoneAccount;
35import android.telecom.VideoProfile;
36import android.text.BidiFormatter;
37import android.text.TextDirectionHeuristics;
38import android.text.TextUtils;
39import android.util.ArrayMap;
40import com.android.contacts.common.ContactsUtils;
41import com.android.contacts.common.compat.CallCompat;
42import com.android.contacts.common.preference.ContactsPreferences;
43import com.android.contacts.common.util.BitmapUtil;
44import com.android.contacts.common.util.ContactDisplayUtils;
Eric Erfanianea7890c2017-06-19 12:40:59 -070045import com.android.dialer.notification.NotificationChannelId;
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 Erfanianea7890c2017-06-19 12:40:59 -070062 private static final String NOTIFICATION_TAG = "EXTERNAL_CALL";
Eric Erfanianccca3152017-02-22 16:32:36 -080063
Eric Erfanianea7890c2017-06-19 12:40:59 -070064 private static final int NOTIFICATION_SUMMARY_ID = -1;
65
Eric Erfanianccca3152017-02-22 16:32:36 -080066 private final Context mContext;
67 private final ContactInfoCache mContactInfoCache;
68 private Map<Call, NotificationInfo> mNotifications = new ArrayMap<>();
69 private int mNextUniqueNotificationId;
70 private ContactsPreferences mContactsPreferences;
71 private boolean mShowingSummary;
72
73 /** Initializes a new instance of the external call notifier. */
74 public ExternalCallNotifier(
75 @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) {
76 mContext = context;
77 mContactsPreferences = ContactsPreferencesFactory.newContactsPreferences(mContext);
78 mContactInfoCache = contactInfoCache;
79 }
80
81 /**
82 * Handles the addition of a new external call by showing a new notification. Triggered by {@link
83 * CallList#onCallAdded(android.telecom.Call)}.
84 */
85 @Override
86 public void onExternalCallAdded(android.telecom.Call call) {
87 Log.i(this, "onExternalCallAdded " + call);
88 if (mNotifications.containsKey(call)) {
89 throw new IllegalArgumentException();
90 }
91 NotificationInfo info = new NotificationInfo(call, mNextUniqueNotificationId++);
92 mNotifications.put(call, info);
93
94 showNotifcation(info);
95 }
96
97 /**
98 * Handles the removal of an external call by hiding its associated notification. Triggered by
99 * {@link CallList#onCallRemoved(android.telecom.Call)}.
100 */
101 @Override
102 public void onExternalCallRemoved(android.telecom.Call call) {
103 Log.i(this, "onExternalCallRemoved " + call);
104
105 dismissNotification(call);
106 }
107
108 /** Handles updates to an external call. */
109 @Override
110 public void onExternalCallUpdated(Call call) {
111 if (!mNotifications.containsKey(call)) {
112 throw new IllegalArgumentException();
113 }
114 postNotification(mNotifications.get(call));
115 }
116
117 @Override
118 public void onExternalCallPulled(Call call) {
119 // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved.
120 }
121
122 /**
123 * Initiates a call pull given a notification ID.
124 *
125 * @param notificationId The notification ID associated with the external call which is to be
126 * pulled.
127 */
128 @TargetApi(VERSION_CODES.N_MR1)
129 public void pullExternalCall(int notificationId) {
130 for (NotificationInfo info : mNotifications.values()) {
131 if (info.getNotificationId() == notificationId
132 && CallCompat.canPullExternalCall(info.getCall())) {
133 info.getCall().pullExternalCall();
134 return;
135 }
136 }
137 }
138
139 /**
140 * Shows a notification for a new external call. Performs a contact cache lookup to find any
141 * associated photo and information for the call.
142 */
143 private void showNotifcation(final NotificationInfo info) {
144 // We make a call to the contact info cache to query for supplemental data to what the
145 // call provides. This includes the contact name and photo.
146 // This callback will always get called immediately and synchronously with whatever data
147 // it has available, and may make a subsequent call later (same thread) if it had to
148 // call into the contacts provider for more data.
149 DialerCall dialerCall =
150 new DialerCall(
151 mContext,
152 new DialerCallDelegateStub(),
153 info.getCall(),
154 new LatencyReport(),
155 false /* registerCallback */);
156
157 mContactInfoCache.findInfo(
158 dialerCall,
159 false /* isIncoming */,
160 new ContactInfoCache.ContactInfoCacheCallback() {
161 @Override
162 public void onContactInfoComplete(
163 String callId, ContactInfoCache.ContactCacheEntry entry) {
164
165 // Ensure notification still exists as the external call could have been
166 // removed during async contact info lookup.
167 if (mNotifications.containsKey(info.getCall())) {
168 saveContactInfo(info, entry);
169 }
170 }
171
172 @Override
173 public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) {
174
175 // Ensure notification still exists as the external call could have been
176 // removed during async contact info lookup.
177 if (mNotifications.containsKey(info.getCall())) {
178 savePhoto(info, entry);
179 }
180 }
181 });
182 }
183
184 /** Dismisses a notification for an external call. */
185 private void dismissNotification(Call call) {
186 if (!mNotifications.containsKey(call)) {
187 throw new IllegalArgumentException();
188 }
189
190 NotificationManager notificationManager =
191 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
Eric Erfanianea7890c2017-06-19 12:40:59 -0700192 notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId());
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 Erfanianea7890c2017-06-19 12:40:59 -0700199 notificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_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 Erfanianea7890c2017-06-19 12:40:59 -0700240 builder.setGroup(NOTIFICATION_TAG);
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());
Eric Erfanianea7890c2017-06-19 12:40:59 -0700254 if (BuildCompat.isAtLeastO()) {
255 builder.setChannelId(NotificationChannelId.DEFAULT);
256 }
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700257
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));
Eric Erfanianea7890c2017-06-19 12:40:59 -0700289 if (BuildCompat.isAtLeastO()) {
290 publicBuilder.setChannelId(NotificationChannelId.DEFAULT);
291 }
Eric Erfaniand5e47f62017-03-15 14:41:07 -0700292
Eric Erfanianccca3152017-02-22 16:32:36 -0800293 builder.setPublicVersion(publicBuilder.build());
294 Notification notification = builder.build();
295
296 NotificationManager notificationManager =
297 (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
Eric Erfanianea7890c2017-06-19 12:40:59 -0700298 notificationManager.notify(NOTIFICATION_TAG, info.getNotificationId(), notification);
Eric Erfanianccca3152017-02-22 16:32:36 -0800299
300 if (!mShowingSummary && mNotifications.size() > 1) {
301 // If the number of notifications shown is > 1, and we're not already showing a group summary,
302 // build one now. This will ensure the like notifications are grouped together.
303
304 Notification.Builder summary = new Notification.Builder(mContext);
305 // Set notification as ongoing since calls are long-running versus a point-in-time notice.
306 summary.setOngoing(true);
307 // Make the notification prioritized over the other normal notifications.
308 summary.setPriority(Notification.PRIORITY_HIGH);
Eric Erfanianea7890c2017-06-19 12:40:59 -0700309 summary.setGroup(NOTIFICATION_TAG);
Eric Erfanianccca3152017-02-22 16:32:36 -0800310 summary.setGroupSummary(true);
311 summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
Eric Erfanianea7890c2017-06-19 12:40:59 -0700312 if (BuildCompat.isAtLeastO()) {
313 summary.setChannelId(NotificationChannelId.DEFAULT);
314 }
315 notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_ID, summary.build());
Eric Erfanianccca3152017-02-22 16:32:36 -0800316 mShowingSummary = true;
317 }
318 }
319
320 /**
321 * Finds a large icon to display in a notification for a call. For conference calls, a conference
322 * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar
323 * is used.
324 *
325 * @param context The context.
326 * @param contactInfo The contact cache info.
327 * @param call The call.
328 * @return The large icon to use for the notification.
329 */
330 private @Nullable Bitmap getLargeIconToDisplay(
331 Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) {
332
333 Bitmap largeIcon = null;
334 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
335 && !call.getDetails()
336 .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
337
Eric Erfanian83b20212017-05-31 08:53:10 -0700338 largeIcon =
339 BitmapFactory.decodeResource(
340 context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
Eric Erfanianccca3152017-02-22 16:32:36 -0800341 }
342 if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
343 largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
344 }
345 return largeIcon;
346 }
347
348 /**
349 * Given a bitmap, returns a rounded version of the icon suitable for display in a notification.
350 *
351 * @param context The context.
352 * @param bitmap The bitmap to round.
353 * @return The rounded bitmap.
354 */
355 private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) {
356 if (bitmap == null) {
357 return null;
358 }
359 final int height =
360 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height);
361 final int width =
362 (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width);
363 return BitmapUtil.getRoundedBitmap(bitmap, width, height);
364 }
365
366 /**
367 * Builds a notification content title for a call. If the call is a conference call, it is
368 * identified as such. Otherwise an attempt is made to show an associated contact name or phone
369 * number.
370 *
371 * @param context The context.
372 * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for
373 * contact names.
374 * @param contactInfo The contact info which was looked up in the contact cache.
375 * @param call The call to generate a title for.
376 * @return The content title.
377 */
378 private @Nullable String getContentTitle(
379 Context context,
380 @Nullable ContactsPreferences contactsPreferences,
381 ContactInfoCache.ContactCacheEntry contactInfo,
382 android.telecom.Call call) {
383
Eric Erfanian91ce7d22017-06-05 13:35:02 -0700384 if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)) {
385 return CallerInfoUtils.getConferenceString(
386 context,
387 call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE));
Eric Erfanianccca3152017-02-22 16:32:36 -0800388 }
389
390 String preferredName =
391 ContactDisplayUtils.getPreferredDisplayName(
392 contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
393 if (TextUtils.isEmpty(preferredName)) {
394 return TextUtils.isEmpty(contactInfo.number)
395 ? null
396 : BidiFormatter.getInstance()
397 .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
398 }
399 return preferredName;
400 }
401
402 /**
403 * Gets a "person reference" for a notification, used by the system to determine whether the
404 * notification should be allowed past notification interruption filters.
405 *
406 * @param contactInfo The contact info from cache.
407 * @param call The call.
408 * @return the person reference.
409 */
410 private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
411
412 String number = TelecomCallUtil.getNumber(call);
413 // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
414 // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
415 // NotificationManager using it.
416 if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
417 return contactInfo.lookupUri.toString();
418 } else if (!TextUtils.isEmpty(number)) {
419 return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
420 }
421 return "";
422 }
423
424 private static class DialerCallDelegateStub implements DialerCallDelegate {
425
426 @Override
427 public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
428 return null;
429 }
430 }
431
432 /** Represents a call and associated cached notification data. */
433 private static class NotificationInfo {
434
435 @NonNull private final Call mCall;
436 private final int mNotificationId;
437 @Nullable private String mContentTitle;
438 @Nullable private Bitmap mLargeIcon;
439 @Nullable private String mPersonReference;
440
441 public NotificationInfo(@NonNull Call call, int notificationId) {
442 mCall = call;
443 mNotificationId = notificationId;
444 }
445
446 public Call getCall() {
447 return mCall;
448 }
449
450 public int getNotificationId() {
451 return mNotificationId;
452 }
453
454 public @Nullable String getContentTitle() {
455 return mContentTitle;
456 }
457
458 public void setContentTitle(@Nullable String contentTitle) {
459 mContentTitle = contentTitle;
460 }
461
462 public @Nullable Bitmap getLargeIcon() {
463 return mLargeIcon;
464 }
465
466 public void setLargeIcon(@Nullable Bitmap largeIcon) {
467 mLargeIcon = largeIcon;
468 }
469
470 public @Nullable String getPersonReference() {
471 return mPersonReference;
472 }
473
474 public void setPersonReference(@Nullable String personReference) {
475 mPersonReference = personReference;
476 }
477 }
478}