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.PendingIntent;
22import android.content.Context;
23import android.content.Intent;
24import android.graphics.Bitmap;
25import android.graphics.BitmapFactory;
26import android.graphics.drawable.BitmapDrawable;
27import android.net.Uri;
28import android.os.Build.VERSION_CODES;
29import android.support.annotation.NonNull;
30import android.support.annotation.Nullable;
31import android.support.v4.os.BuildCompat;
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.ContactDisplayUtils;
43import com.android.dialer.common.Assert;
44import com.android.dialer.contactphoto.BitmapUtil;
45import com.android.dialer.notification.DialerNotificationManager;
46import com.android.dialer.notification.NotificationChannelId;
47import com.android.dialer.telecom.TelecomCallUtil;
48import com.android.incallui.call.DialerCall;
49import com.android.incallui.call.DialerCallDelegate;
50import com.android.incallui.call.ExternalCallList;
51import com.android.incallui.latencyreport.LatencyReport;
52import java.util.Map;
53
54/**
55 * Handles the display of notifications for "external calls".
56 *
57 * <p>External calls are a representation of a call which is in progress on the user's other device
58 * (e.g. another phone, or a watch).
59 */
60public class ExternalCallNotifier implements ExternalCallList.ExternalCallListener {
61
62  /**
63   * Common tag for all external call notifications. Unlike other grouped notifications in Dialer,
64   * external call notifications are uniquely identified by ID.
65   */
66  private static final String NOTIFICATION_TAG = "EXTERNAL_CALL";
67
68  private static final int GROUP_SUMMARY_NOTIFICATION_ID = -1;
69  private static final String GROUP_SUMMARY_NOTIFICATION_TAG = "GroupSummary_ExternalCall";
70  /**
71   * Key used to associate all external call notifications and the summary as belonging to a single
72   * group.
73   */
74  private static final String GROUP_KEY = "ExternalCallGroup";
75
76  private final Context context;
77  private final ContactInfoCache contactInfoCache;
78  private Map<Call, NotificationInfo> notifications = new ArrayMap<>();
79  private int nextUniqueNotificationId;
80  private ContactsPreferences contactsPreferences;
81
82  /** Initializes a new instance of the external call notifier. */
83  public ExternalCallNotifier(
84      @NonNull Context context, @NonNull ContactInfoCache contactInfoCache) {
85    this.context = context;
86    contactsPreferences = ContactsPreferencesFactory.newContactsPreferences(this.context);
87    this.contactInfoCache = contactInfoCache;
88  }
89
90  /**
91   * Handles the addition of a new external call by showing a new notification. Triggered by {@link
92   * CallList#onCallAdded(android.telecom.Call)}.
93   */
94  @Override
95  public void onExternalCallAdded(android.telecom.Call call) {
96    Log.i(this, "onExternalCallAdded " + call);
97    Assert.checkArgument(!notifications.containsKey(call));
98    NotificationInfo info = new NotificationInfo(call, nextUniqueNotificationId++);
99    notifications.put(call, info);
100
101    showNotifcation(info);
102  }
103
104  /**
105   * Handles the removal of an external call by hiding its associated notification. Triggered by
106   * {@link CallList#onCallRemoved(android.telecom.Call)}.
107   */
108  @Override
109  public void onExternalCallRemoved(android.telecom.Call call) {
110    Log.i(this, "onExternalCallRemoved " + call);
111
112    dismissNotification(call);
113  }
114
115  /** Handles updates to an external call. */
116  @Override
117  public void onExternalCallUpdated(Call call) {
118    Assert.checkArgument(notifications.containsKey(call));
119    postNotification(notifications.get(call));
120  }
121
122  @Override
123  public void onExternalCallPulled(Call call) {
124    // no-op; if an external call is pulled, it will be removed via onExternalCallRemoved.
125  }
126
127  /**
128   * Initiates a call pull given a notification ID.
129   *
130   * @param notificationId The notification ID associated with the external call which is to be
131   *     pulled.
132   */
133  @TargetApi(VERSION_CODES.N_MR1)
134  public void pullExternalCall(int notificationId) {
135    for (NotificationInfo info : notifications.values()) {
136      if (info.getNotificationId() == notificationId
137          && CallCompat.canPullExternalCall(info.getCall())) {
138        info.getCall().pullExternalCall();
139        return;
140      }
141    }
142  }
143
144  /**
145   * Shows a notification for a new external call. Performs a contact cache lookup to find any
146   * associated photo and information for the call.
147   */
148  private void showNotifcation(final NotificationInfo info) {
149    // We make a call to the contact info cache to query for supplemental data to what the
150    // call provides.  This includes the contact name and photo.
151    // This callback will always get called immediately and synchronously with whatever data
152    // it has available, and may make a subsequent call later (same thread) if it had to
153    // call into the contacts provider for more data.
154    DialerCall dialerCall =
155        new DialerCall(
156            context,
157            new DialerCallDelegateStub(),
158            info.getCall(),
159            new LatencyReport(),
160            false /* registerCallback */);
161
162    contactInfoCache.findInfo(
163        dialerCall,
164        false /* isIncoming */,
165        new ContactInfoCache.ContactInfoCacheCallback() {
166          @Override
167          public void onContactInfoComplete(
168              String callId, ContactInfoCache.ContactCacheEntry entry) {
169
170            // Ensure notification still exists as the external call could have been
171            // removed during async contact info lookup.
172            if (notifications.containsKey(info.getCall())) {
173              saveContactInfo(info, entry);
174            }
175          }
176
177          @Override
178          public void onImageLoadComplete(String callId, ContactInfoCache.ContactCacheEntry entry) {
179
180            // Ensure notification still exists as the external call could have been
181            // removed during async contact info lookup.
182            if (notifications.containsKey(info.getCall())) {
183              savePhoto(info, entry);
184            }
185          }
186        });
187  }
188
189  /** Dismisses a notification for an external call. */
190  private void dismissNotification(Call call) {
191    Assert.checkArgument(notifications.containsKey(call));
192
193    // This will also dismiss the group summary if there are no more external call notifications.
194    DialerNotificationManager.cancel(
195        context, NOTIFICATION_TAG, notifications.get(call).getNotificationId());
196
197    notifications.remove(call);
198  }
199
200  /**
201   * Attempts to build a large icon to use for the notification based on the contact info and post
202   * the updated notification to the notification manager.
203   */
204  private void savePhoto(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) {
205    Bitmap largeIcon = getLargeIconToDisplay(context, entry, info.getCall());
206    if (largeIcon != null) {
207      largeIcon = getRoundedIcon(context, largeIcon);
208    }
209    info.setLargeIcon(largeIcon);
210    postNotification(info);
211  }
212
213  /**
214   * Builds and stores the contact information the notification will display and posts the updated
215   * notification to the notification manager.
216   */
217  private void saveContactInfo(NotificationInfo info, ContactInfoCache.ContactCacheEntry entry) {
218    info.setContentTitle(getContentTitle(context, contactsPreferences, entry, info.getCall()));
219    info.setPersonReference(getPersonReference(entry, info.getCall()));
220    postNotification(info);
221  }
222
223  /** Rebuild an existing or show a new notification given {@link NotificationInfo}. */
224  private void postNotification(NotificationInfo info) {
225    Notification.Builder builder = new Notification.Builder(context);
226    // Set notification as ongoing since calls are long-running versus a point-in-time notice.
227    builder.setOngoing(true);
228    // Make the notification prioritized over the other normal notifications.
229    builder.setPriority(Notification.PRIORITY_HIGH);
230    builder.setGroup(GROUP_KEY);
231
232    boolean isVideoCall = VideoProfile.isVideo(info.getCall().getDetails().getVideoState());
233    // Set the content ("Ongoing call on another device")
234    builder.setContentText(
235        context.getString(
236            isVideoCall
237                ? R.string.notification_external_video_call
238                : R.string.notification_external_call));
239    builder.setSmallIcon(R.drawable.quantum_ic_call_white_24);
240    builder.setContentTitle(info.getContentTitle());
241    builder.setLargeIcon(info.getLargeIcon());
242    builder.setColor(context.getResources().getColor(R.color.dialer_theme_color));
243    builder.addPerson(info.getPersonReference());
244    if (BuildCompat.isAtLeastO()) {
245      builder.setChannelId(NotificationChannelId.DEFAULT);
246    }
247
248    // Where the external call supports being transferred to the local device, add an action
249    // to the notification to initiate the call pull process.
250    if (CallCompat.canPullExternalCall(info.getCall())) {
251
252      Intent intent =
253          new Intent(
254              NotificationBroadcastReceiver.ACTION_PULL_EXTERNAL_CALL,
255              null,
256              context,
257              NotificationBroadcastReceiver.class);
258      intent.putExtra(
259          NotificationBroadcastReceiver.EXTRA_NOTIFICATION_ID, info.getNotificationId());
260      builder.addAction(
261          new Notification.Action.Builder(
262                  R.drawable.quantum_ic_call_white_24,
263                  context.getString(
264                      isVideoCall
265                          ? R.string.notification_take_video_call
266                          : R.string.notification_take_call),
267                  PendingIntent.getBroadcast(context, info.getNotificationId(), intent, 0))
268              .build());
269    }
270
271    /**
272     * This builder is used for the notification shown when the device is locked and the user has
273     * set their notification settings to 'hide sensitive content' {@see
274     * Notification.Builder#setPublicVersion}.
275     */
276    Notification.Builder publicBuilder = new Notification.Builder(context);
277    publicBuilder.setSmallIcon(R.drawable.quantum_ic_call_white_24);
278    publicBuilder.setColor(context.getResources().getColor(R.color.dialer_theme_color));
279    if (BuildCompat.isAtLeastO()) {
280      publicBuilder.setChannelId(NotificationChannelId.DEFAULT);
281    }
282
283    builder.setPublicVersion(publicBuilder.build());
284    Notification notification = builder.build();
285
286    DialerNotificationManager.notify(
287        context, NOTIFICATION_TAG, info.getNotificationId(), notification);
288
289    showGroupSummaryNotification(context);
290  }
291
292  /**
293   * Finds a large icon to display in a notification for a call. For conference calls, a conference
294   * call icon is used, otherwise if contact info is specified, the user's contact photo or avatar
295   * is used.
296   *
297   * @param context The context.
298   * @param contactInfo The contact cache info.
299   * @param call The call.
300   * @return The large icon to use for the notification.
301   */
302  private @Nullable Bitmap getLargeIconToDisplay(
303      Context context, ContactInfoCache.ContactCacheEntry contactInfo, android.telecom.Call call) {
304
305    Bitmap largeIcon = null;
306    if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)
307        && !call.getDetails()
308            .hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE)) {
309
310      largeIcon =
311          BitmapFactory.decodeResource(
312              context.getResources(), R.drawable.quantum_ic_group_vd_theme_24);
313    }
314    if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
315      largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
316    }
317    return largeIcon;
318  }
319
320  /**
321   * Given a bitmap, returns a rounded version of the icon suitable for display in a notification.
322   *
323   * @param context The context.
324   * @param bitmap The bitmap to round.
325   * @return The rounded bitmap.
326   */
327  private @Nullable Bitmap getRoundedIcon(Context context, @Nullable Bitmap bitmap) {
328    if (bitmap == null) {
329      return null;
330    }
331    final int height =
332        (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_height);
333    final int width =
334        (int) context.getResources().getDimension(android.R.dimen.notification_large_icon_width);
335    return BitmapUtil.getRoundedBitmap(bitmap, width, height);
336  }
337
338  /**
339   * Builds a notification content title for a call. If the call is a conference call, it is
340   * identified as such. Otherwise an attempt is made to show an associated contact name or phone
341   * number.
342   *
343   * @param context The context.
344   * @param contactsPreferences Contacts preferences, used to determine the preferred formatting for
345   *     contact names.
346   * @param contactInfo The contact info which was looked up in the contact cache.
347   * @param call The call to generate a title for.
348   * @return The content title.
349   */
350  private @Nullable String getContentTitle(
351      Context context,
352      @Nullable ContactsPreferences contactsPreferences,
353      ContactInfoCache.ContactCacheEntry contactInfo,
354      android.telecom.Call call) {
355
356    if (call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_CONFERENCE)) {
357      return CallerInfoUtils.getConferenceString(
358          context,
359          call.getDetails().hasProperty(android.telecom.Call.Details.PROPERTY_GENERIC_CONFERENCE));
360    }
361
362    String preferredName =
363        ContactDisplayUtils.getPreferredDisplayName(
364            contactInfo.namePrimary, contactInfo.nameAlternative, contactsPreferences);
365    if (TextUtils.isEmpty(preferredName)) {
366      return TextUtils.isEmpty(contactInfo.number)
367          ? null
368          : BidiFormatter.getInstance()
369              .unicodeWrap(contactInfo.number, TextDirectionHeuristics.LTR);
370    }
371    return preferredName;
372  }
373
374  /**
375   * Gets a "person reference" for a notification, used by the system to determine whether the
376   * notification should be allowed past notification interruption filters.
377   *
378   * @param contactInfo The contact info from cache.
379   * @param call The call.
380   * @return the person reference.
381   */
382  private String getPersonReference(ContactInfoCache.ContactCacheEntry contactInfo, Call call) {
383
384    String number = TelecomCallUtil.getNumber(call);
385    // Query {@link Contacts#CONTENT_LOOKUP_URI} directly with work lookup key is not allowed.
386    // So, do not pass {@link Contacts#CONTENT_LOOKUP_URI} to NotificationManager to avoid
387    // NotificationManager using it.
388    if (contactInfo.lookupUri != null && contactInfo.userType != ContactsUtils.USER_TYPE_WORK) {
389      return contactInfo.lookupUri.toString();
390    } else if (!TextUtils.isEmpty(number)) {
391      return Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null).toString();
392    }
393    return "";
394  }
395
396  private static class DialerCallDelegateStub implements DialerCallDelegate {
397
398    @Override
399    public DialerCall getDialerCallFromTelecomCall(Call telecomCall) {
400      return null;
401    }
402  }
403
404  /** Represents a call and associated cached notification data. */
405  private static class NotificationInfo {
406
407    @NonNull private final Call call;
408    private final int notificationId;
409    @Nullable private String contentTitle;
410    @Nullable private Bitmap largeIcon;
411    @Nullable private String personReference;
412
413    public NotificationInfo(@NonNull Call call, int notificationId) {
414      this.call = call;
415      this.notificationId = notificationId;
416    }
417
418    public Call getCall() {
419      return call;
420    }
421
422    public int getNotificationId() {
423      return notificationId;
424    }
425
426    public @Nullable String getContentTitle() {
427      return contentTitle;
428    }
429
430    public void setContentTitle(@Nullable String contentTitle) {
431      this.contentTitle = contentTitle;
432    }
433
434    public @Nullable Bitmap getLargeIcon() {
435      return largeIcon;
436    }
437
438    public void setLargeIcon(@Nullable Bitmap largeIcon) {
439      this.largeIcon = largeIcon;
440    }
441
442    public @Nullable String getPersonReference() {
443      return personReference;
444    }
445
446    public void setPersonReference(@Nullable String personReference) {
447      this.personReference = personReference;
448    }
449  }
450
451  private static void showGroupSummaryNotification(@NonNull Context context) {
452    Notification.Builder summary = new Notification.Builder(context);
453    // Set notification as ongoing since calls are long-running versus a point-in-time notice.
454    summary.setOngoing(true);
455    // Make the notification prioritized over the other normal notifications.
456    summary.setPriority(Notification.PRIORITY_HIGH);
457    summary.setGroup(GROUP_KEY);
458    summary.setGroupSummary(true);
459    summary.setSmallIcon(R.drawable.quantum_ic_call_white_24);
460    if (BuildCompat.isAtLeastO()) {
461      summary.setChannelId(NotificationChannelId.DEFAULT);
462    }
463    DialerNotificationManager.notify(
464        context, GROUP_SUMMARY_NOTIFICATION_TAG, GROUP_SUMMARY_NOTIFICATION_ID, summary.build());
465  }
466}
467