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.support.v4.os.BuildCompat;
33import 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;
45import com.android.dialer.notification.NotificationChannelId;
46import 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. */
62  private static final String NOTIFICATION_TAG = "EXTERNAL_CALL";
63
64  private static final int NOTIFICATION_SUMMARY_ID = -1;
65
66  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);
192    notificationManager.cancel(NOTIFICATION_TAG, mNotifications.get(call).getNotificationId());
193
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.
199      notificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_ID);
200      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);
240    builder.setGroup(NOTIFICATION_TAG);
241
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    if (BuildCompat.isAtLeastO()) {
255      builder.setChannelId(NotificationChannelId.DEFAULT);
256    }
257
258    // 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    if (BuildCompat.isAtLeastO()) {
290      publicBuilder.setChannelId(NotificationChannelId.DEFAULT);
291    }
292
293    builder.setPublicVersion(publicBuilder.build());
294    Notification notification = builder.build();
295
296    NotificationManager notificationManager =
297        (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
298    notificationManager.notify(NOTIFICATION_TAG, info.getNotificationId(), notification);
299
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);
309      summary.setGroup(NOTIFICATION_TAG);
310      summary.setGroupSummary(true);
311      summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
312      if (BuildCompat.isAtLeastO()) {
313        summary.setChannelId(NotificationChannelId.DEFAULT);
314      }
315      notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_SUMMARY_ID, summary.build());
316      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
338      largeIcon =
339          BitmapFactory.decodeResource(
340              context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
341    }
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
384    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));
388    }
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}
479