ExternalCallNotifier.java revision ccca31529c07970e89419fb85a9e8153a5396838
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
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}
466