1/*
2 * Copyright (C) 2013 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 com.android.services.telephony.common.CallIdentification;
20import com.google.common.base.Preconditions;
21
22import android.app.Notification;
23import android.app.NotificationManager;
24import android.app.PendingIntent;
25import android.content.Context;
26import android.content.Intent;
27import android.graphics.Bitmap;
28import android.graphics.BitmapFactory;
29import android.graphics.drawable.BitmapDrawable;
30import android.graphics.drawable.Drawable;
31import android.text.TextUtils;
32
33import com.android.incallui.ContactInfoCache.ContactCacheEntry;
34import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback;
35import com.android.incallui.InCallApp.NotificationBroadcastReceiver;
36import com.android.incallui.InCallPresenter.InCallState;
37import com.android.services.telephony.common.Call;
38
39import java.util.HashMap;
40
41/**
42 * This class adds Notifications to the status bar for the in-call experience.
43 */
44public class StatusBarNotifier implements InCallPresenter.InCallStateListener {
45    // notification types
46    private static final int IN_CALL_NOTIFICATION = 1;
47
48    private final Context mContext;
49    private final ContactInfoCache mContactInfoCache;
50    private final CallList mCallList;
51    private final NotificationManager mNotificationManager;
52    private boolean mIsShowingNotification = false;
53    private int mCallState = Call.State.INVALID;
54    private int mSavedIcon = 0;
55    private int mSavedContent = 0;
56    private Bitmap mSavedLargeIcon;
57    private String mSavedContentTitle;
58
59    public StatusBarNotifier(Context context, ContactInfoCache contactInfoCache,
60            CallList callList) {
61        Preconditions.checkNotNull(context);
62
63        mContext = context;
64        mContactInfoCache = contactInfoCache;
65        mCallList = callList;
66        mNotificationManager =
67                (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
68    }
69
70    /**
71     * Creates notifications according to the state we receive from {@link InCallPresenter}.
72     */
73    @Override
74    public void onStateChange(InCallState state, CallList callList) {
75        Log.d(this, "onStateChange");
76
77        updateNotification(state, callList);
78    }
79
80    /**
81     * Updates the phone app's status bar notification based on the
82     * current telephony state, or cancels the notification if the phone
83     * is totally idle.
84     *
85     * This method will never actually launch the incoming-call UI.
86     * (Use updateNotificationAndLaunchIncomingCallUi() for that.)
87     */
88    public void updateNotification(InCallState state, CallList callList) {
89        Log.d(this, "updateNotification");
90        // allowFullScreenIntent=false means *don't* allow the incoming
91        // call UI to be launched.
92        updateInCallNotification(false, state, callList);
93    }
94
95    /**
96     * Updates the phone app's status bar notification *and* launches the
97     * incoming call UI in response to a new incoming call.
98     *
99     * This is just like updateInCallNotification(), with one exception:
100     * If an incoming call is ringing (or call-waiting), the notification
101     * will also include a "fullScreenIntent" that will cause the
102     * InCallScreen to be launched immediately, unless the current
103     * foreground activity is marked as "immersive".
104     *
105     * (This is the mechanism that actually brings up the incoming call UI
106     * when we receive a "new ringing connection" event from the telephony
107     * layer.)
108     *
109     * Watch out: this method should ONLY be called directly from the code
110     * path in CallNotifier that handles the "new ringing connection"
111     * event from the telephony layer.  All other places that update the
112     * in-call notification (like for phone state changes) should call
113     * updateInCallNotification() instead.  (This ensures that we don't
114     * end up launching the InCallScreen multiple times for a single
115     * incoming call, which could cause slow responsiveness and/or visible
116     * glitches.)
117     *
118     * Also note that this method is safe to call even if the phone isn't
119     * actually ringing (or, more likely, if an incoming call *was*
120     * ringing briefly but then disconnected).  In that case, we'll simply
121     * update or cancel the in-call notification based on the current
122     * phone state.
123     *
124     * @see #updateInCallNotification(boolean)
125     */
126    public void updateNotificationAndLaunchIncomingCallUi(InCallState state, CallList callList) {
127        // Set allowFullScreenIntent=true to indicate that we *should*
128        // launch the incoming call UI if necessary.
129        updateInCallNotification(true, state, callList);
130    }
131
132    /**
133     * Take down the in-call notification.
134     * @see updateInCallNotification()
135     */
136    private void cancelInCall() {
137        Log.d(this, "cancelInCall()...");
138        mNotificationManager.cancel(IN_CALL_NOTIFICATION);
139
140        mIsShowingNotification = false;
141    }
142
143    /* package */ static void clearInCallNotification(Context backupContext) {
144        Log.i(StatusBarNotifier.class.getSimpleName(),
145                "Something terrible happened. Clear all InCall notifications");
146
147        NotificationManager notificationManager =
148                (NotificationManager) backupContext.getSystemService(Context.NOTIFICATION_SERVICE);
149        notificationManager.cancel(IN_CALL_NOTIFICATION);
150    }
151
152    /**
153     * Helper method for updateInCallNotification() and
154     * updateNotificationAndLaunchIncomingCallUi(): Update the phone app's
155     * status bar notification based on the current telephony state, or
156     * cancels the notification if the phone is totally idle.
157     *
158     * @param allowFullScreenIntent If true, *and* an incoming call is
159     *   ringing, the notification will include a "fullScreenIntent"
160     *   pointing at the InCallActivity (which will cause the InCallActivity
161     *   to be launched.)
162     *   Watch out: This should be set to true *only* when directly
163     *   handling a new incoming call for the first time.
164     */
165    private void updateInCallNotification(final boolean allowFullScreenIntent,
166            final InCallState state, CallList callList) {
167        Log.d(this, "updateInCallNotification(allowFullScreenIntent = "
168                + allowFullScreenIntent + ")...");
169
170        final Call call = getCallToShow(callList);
171        if (shouldSuppressNotification(state, call)) {
172            Log.d(this, "Suppressing notification");
173            cancelInCall();
174            return;
175        }
176
177        // we make a call to the contact info cache to query for supplemental data to what the
178        // call provides.  This includes the contact name and photo.
179        // This callback will always get called immediately and synchronously with whatever data
180        // it has available, and may make a subsequent call later (same thread) if it had to
181        // call into the contacts provider for more data.
182        mContactInfoCache.findInfo(call.getIdentification(), call.getState() == Call.State.INCOMING,
183                new ContactInfoCacheCallback() {
184                    private boolean mAllowFullScreenIntent = allowFullScreenIntent;
185
186                    @Override
187                    public void onContactInfoComplete(int callId, ContactCacheEntry entry) {
188                        Call call = CallList.getInstance().getCall(callId);
189                        if (call != null) {
190                            buildAndSendNotification(call, entry, mAllowFullScreenIntent);
191                        }
192
193                        // Full screen intents are what bring up the in call screen. We only want
194                        // to do this the first time we are called back.
195                        mAllowFullScreenIntent = false;
196                    }
197
198                    @Override
199                    public void onImageLoadComplete(int callId, ContactCacheEntry entry) {
200                        Call call = CallList.getInstance().getCall(callId);
201                        if (call != null) {
202                            buildAndSendNotification(call, entry, mAllowFullScreenIntent);
203                        }
204                    } });
205    }
206
207    /**
208     * Sets up the main Ui for the notification
209     */
210    private void buildAndSendNotification(Call originalCall, ContactCacheEntry contactInfo,
211            boolean allowFullScreenIntent) {
212
213        // This can get called to update an existing notification after contact information has come
214        // back. However, it can happen much later. Before we continue, we need to make sure that
215        // the call being passed in is still the one we want to show in the notification.
216        final Call call = getCallToShow(CallList.getInstance());
217        if (call == null || call.getCallId() != originalCall.getCallId()) {
218            return;
219        }
220
221        final int state = call.getState();
222        final boolean isConference = call.isConferenceCall();
223        final int iconResId = getIconToDisplay(call);
224        final Bitmap largeIcon = getLargeIconToDisplay(contactInfo, isConference);
225        final int contentResId = getContentString(call);
226        final String contentTitle = getContentTitle(contactInfo, isConference);
227
228        // If we checked and found that nothing is different, dont issue another notification.
229        if (!checkForChangeAndSaveData(iconResId, contentResId, largeIcon, contentTitle, state,
230                allowFullScreenIntent)) {
231            return;
232        }
233
234        /*
235         * Nothing more to check...build and send it.
236         */
237        final Notification.Builder builder = getNotificationBuilder();
238
239        // Set up the main intent to send the user to the in-call screen
240        final PendingIntent inCallPendingIntent = createLaunchPendingIntent();
241        builder.setContentIntent(inCallPendingIntent);
242
243        // Set the intent as a full screen intent as well if requested
244        if (allowFullScreenIntent) {
245            configureFullScreenIntent(builder, inCallPendingIntent, call);
246        }
247
248        // set the content
249        builder.setContentText(mContext.getString(contentResId));
250        builder.setSmallIcon(iconResId);
251        builder.setContentTitle(contentTitle);
252        builder.setLargeIcon(largeIcon);
253
254        if (state == Call.State.ACTIVE) {
255            builder.setUsesChronometer(true);
256            builder.setWhen(call.getConnectTime());
257        } else {
258            builder.setUsesChronometer(false);
259        }
260
261        // Add hang up option for any active calls (active | onhold), outgoing calls (dialing).
262        if (state == Call.State.ACTIVE ||
263                state == Call.State.ONHOLD ||
264                Call.State.isDialing(state)) {
265            addHangupAction(builder);
266        }
267
268        /*
269         * Fire off the notification
270         */
271        Notification notification = builder.build();
272        Log.d(this, "Notifying IN_CALL_NOTIFICATION: " + notification);
273        mNotificationManager.notify(IN_CALL_NOTIFICATION, notification);
274        mIsShowingNotification = true;
275    }
276
277    /**
278     * Checks the new notification data and compares it against any notification that we
279     * are already displaying. If the data is exactly the same, we return false so that
280     * we do not issue a new notification for the exact same data.
281     */
282    private boolean checkForChangeAndSaveData(int icon, int content, Bitmap largeIcon,
283            String contentTitle, int state, boolean showFullScreenIntent) {
284
285        // The two are different:
286        // if new title is not null, it should be different from saved version OR
287        // if new title is null, the saved version should not be null
288        final boolean contentTitleChanged =
289                (contentTitle != null && !contentTitle.equals(mSavedContentTitle)) ||
290                (contentTitle == null && mSavedContentTitle != null);
291
292        // any change means we are definitely updating
293        boolean retval = (mSavedIcon != icon) || (mSavedContent != content) ||
294                (mCallState != state) || (mSavedLargeIcon != largeIcon) ||
295                contentTitleChanged;
296
297        // A full screen intent means that we have been asked to interrupt an activity,
298        // so we definitely want to show it.
299        if (showFullScreenIntent) {
300            Log.d(this, "Forcing full screen intent");
301            retval = true;
302        }
303
304        // If we aren't showing a notification right now, definitely start showing one.
305        if (!mIsShowingNotification) {
306            Log.d(this, "Showing notification for first time.");
307            retval = true;
308        }
309
310        mSavedIcon = icon;
311        mSavedContent = content;
312        mCallState = state;
313        mSavedLargeIcon = largeIcon;
314        mSavedContentTitle = contentTitle;
315
316        if (retval) {
317            Log.d(this, "Data changed.  Showing notification");
318        }
319
320        return retval;
321    }
322
323    /**
324     * Returns the main string to use in the notification.
325     */
326    private String getContentTitle(ContactCacheEntry contactInfo, boolean isConference) {
327        if (isConference) {
328            return mContext.getResources().getString(R.string.card_title_conf_call);
329        }
330        if (TextUtils.isEmpty(contactInfo.name)) {
331            return contactInfo.number;
332        }
333
334        return contactInfo.name;
335    }
336
337    /**
338     * Gets a large icon from the contact info object to display in the notification.
339     */
340    private Bitmap getLargeIconToDisplay(ContactCacheEntry contactInfo, boolean isConference) {
341        Bitmap largeIcon = null;
342        if (isConference) {
343            largeIcon = BitmapFactory.decodeResource(mContext.getResources(),
344                    R.drawable.picture_conference);
345        }
346        if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
347            largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
348        }
349
350        if (largeIcon != null) {
351            final int height = (int) mContext.getResources().getDimension(
352                    android.R.dimen.notification_large_icon_height);
353            final int width = (int) mContext.getResources().getDimension(
354                    android.R.dimen.notification_large_icon_width);
355            largeIcon = Bitmap.createScaledBitmap(largeIcon, width, height, false);
356        }
357
358        return largeIcon;
359    }
360
361    /**
362     * Returns the appropriate icon res Id to display based on the call for which
363     * we want to display information.
364     */
365    private int getIconToDisplay(Call call) {
366        // Even if both lines are in use, we only show a single item in
367        // the expanded Notifications UI.  It's labeled "Ongoing call"
368        // (or "On hold" if there's only one call, and it's on hold.)
369        // Also, we don't have room to display caller-id info from two
370        // different calls.  So if both lines are in use, display info
371        // from the foreground call.  And if there's a ringing call,
372        // display that regardless of the state of the other calls.
373        if (call.getState() == Call.State.ONHOLD) {
374            return R.drawable.stat_sys_phone_call_on_hold;
375        }
376        return R.drawable.stat_sys_phone_call;
377    }
378
379    /**
380     * Returns the message to use with the notificaiton.
381     */
382    private int getContentString(Call call) {
383        int resId = R.string.notification_ongoing_call;
384
385        if (call.getState() == Call.State.INCOMING) {
386            resId = R.string.notification_incoming_call;
387
388        } else if (call.getState() == Call.State.ONHOLD) {
389            resId = R.string.notification_on_hold;
390
391        } else if (Call.State.isDialing(call.getState())) {
392            resId = R.string.notification_dialing;
393        }
394
395        return resId;
396    }
397
398    /**
399     * Gets the most relevant call to display in the notification.
400     */
401    private Call getCallToShow(CallList callList) {
402        if (callList == null) {
403            return null;
404        }
405        Call call = callList.getIncomingCall();
406        if (call == null) {
407            call = callList.getOutgoingCall();
408        }
409        if (call == null) {
410            call = callList.getActiveOrBackgroundCall();
411        }
412        return call;
413    }
414
415    private void addHangupAction(Notification.Builder builder) {
416        Log.i(this, "Will show \"hang-up\" action in the ongoing active call Notification");
417
418        // TODO: use better asset.
419        builder.addAction(R.drawable.stat_sys_phone_call_end,
420                mContext.getText(R.string.notification_action_end_call),
421                createHangUpOngoingCallPendingIntent(mContext));
422    }
423
424    /**
425     * Adds fullscreen intent to the builder.
426     */
427    private void configureFullScreenIntent(Notification.Builder builder, PendingIntent intent,
428            Call call) {
429        // Ok, we actually want to launch the incoming call
430        // UI at this point (in addition to simply posting a notification
431        // to the status bar).  Setting fullScreenIntent will cause
432        // the InCallScreen to be launched immediately *unless* the
433        // current foreground activity is marked as "immersive".
434        Log.d(this, "- Setting fullScreenIntent: " + intent);
435        builder.setFullScreenIntent(intent, true);
436
437        // Ugly hack alert:
438        //
439        // The NotificationManager has the (undocumented) behavior
440        // that it will *ignore* the fullScreenIntent field if you
441        // post a new Notification that matches the ID of one that's
442        // already active.  Unfortunately this is exactly what happens
443        // when you get an incoming call-waiting call:  the
444        // "ongoing call" notification is already visible, so the
445        // InCallScreen won't get launched in this case!
446        // (The result: if you bail out of the in-call UI while on a
447        // call and then get a call-waiting call, the incoming call UI
448        // won't come up automatically.)
449        //
450        // The workaround is to just notice this exact case (this is a
451        // call-waiting call *and* the InCallScreen is not in the
452        // foreground) and manually cancel the in-call notification
453        // before (re)posting it.
454        //
455        // TODO: there should be a cleaner way of avoiding this
456        // problem (see discussion in bug 3184149.)
457
458        if (call.getState() == Call.State.CALL_WAITING) {
459            Log.i(this, "updateInCallNotification: call-waiting! force relaunch...");
460            // Cancel the IN_CALL_NOTIFICATION immediately before
461            // (re)posting it; this seems to force the
462            // NotificationManager to launch the fullScreenIntent.
463            mNotificationManager.cancel(IN_CALL_NOTIFICATION);
464        }
465    }
466
467    private Notification.Builder getNotificationBuilder() {
468        final Notification.Builder builder = new Notification.Builder(mContext);
469        builder.setOngoing(true);
470
471        // Make the notification prioritized over the other normal notifications.
472        builder.setPriority(Notification.PRIORITY_HIGH);
473
474        return builder;
475    }
476
477    /**
478     * Returns true if notification should not be shown in the current state.
479     */
480    private boolean shouldSuppressNotification(InCallState state, Call call) {
481
482        // We can still be in the INCALL state when a call is disconnected (in order to show
483        // the "Call ended" screen.  So check that we have an active connection too.
484        if (call == null) {
485            Log.v(this, "suppressing: no call");
486            return true;
487        }
488
489        // Suppress the in-call notification if the InCallScreen is the
490        // foreground activity, since it's already obvious that you're on a
491        // call.  (The status bar icon is needed only if you navigate *away*
492        // from the in-call UI.)
493        boolean shouldSuppress = InCallPresenter.getInstance().isShowingInCallUi();
494
495        // Suppress if the call is not active.
496        if (!state.isConnectingOrConnected()) {
497            Log.v(this, "suppressing: not connecting or connected");
498            shouldSuppress = true;
499        }
500
501        // If there's an incoming ringing call: always show the
502        // notification, since the in-call notification is what actually
503        // launches the incoming call UI in the first place (see
504        // notification.fullScreenIntent below.)  This makes sure that we'll
505        // correctly handle the case where a new incoming call comes in but
506        // the InCallScreen is already in the foreground.
507        if (state.isIncoming()) {
508            Log.v(this, "unsuppressing: incoming call");
509            shouldSuppress = false;
510        }
511
512        // JANK Fix
513        // Do not show the notification for outgoing calls until the UI comes up.
514        // Since we don't normally show a notification while the incall screen is
515        // in the foreground, if we show the outgoing notification before the activity
516        // comes up the user will see it flash on and off on an outgoing call.
517        // This code ensures that we do not show the notification for outgoing calls before
518        // the activity has started.
519        if (state == InCallState.OUTGOING &&
520                !InCallPresenter.getInstance().isActivityPreviouslyStarted()) {
521            Log.v(this, "suppressing: activity not started.");
522            shouldSuppress = true;
523        }
524
525        return shouldSuppress;
526    }
527
528    private PendingIntent createLaunchPendingIntent() {
529
530        final Intent intent = InCallPresenter.getInstance().getInCallIntent(/*showdialpad=*/false);
531
532        // PendingIntent that can be used to launch the InCallActivity.  The
533        // system fires off this intent if the user pulls down the windowshade
534        // and clicks the notification's expanded view.  It's also used to
535        // launch the InCallActivity immediately when when there's an incoming
536        // call (see the "fullScreenIntent" field below).
537        PendingIntent inCallPendingIntent = PendingIntent.getActivity(mContext, 0, intent, 0);
538
539        return inCallPendingIntent;
540    }
541
542    /**
543     * Returns PendingIntent for hanging up ongoing phone call. This will typically be used from
544     * Notification context.
545     */
546    private static PendingIntent createHangUpOngoingCallPendingIntent(Context context) {
547        final Intent intent = new Intent(InCallApp.ACTION_HANG_UP_ONGOING_CALL, null,
548                context, NotificationBroadcastReceiver.class);
549        return PendingIntent.getBroadcast(context, 0, intent, 0);
550    }
551}
552