blob: 466e12a6da56bdc2863039771f89e980777a3ea0 [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;
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}