1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
|
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.incallui;
import android.net.Uri;
import com.google.common.base.Preconditions;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.os.Handler;
import android.os.Message;
import android.telecom.Call.Details;
import android.telecom.PhoneAccount;
import android.text.BidiFormatter;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import com.android.contacts.common.util.BitmapUtil;
import com.android.incallui.ContactInfoCache.ContactCacheEntry;
import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback;
import com.android.incallui.InCallApp.NotificationBroadcastReceiver;
import com.android.incallui.InCallPresenter.InCallState;
/**
* This class adds Notifications to the status bar for the in-call experience.
*/
public class StatusBarNotifier implements InCallPresenter.InCallStateListener,
CallList.CallUpdateListener {
// notification types
private static final int IN_CALL_NOTIFICATION = 1;
private final Context mContext;
private final ContactInfoCache mContactInfoCache;
private final NotificationManager mNotificationManager;
private boolean mIsShowingNotification = false;
private int mCallState = Call.State.INVALID;
private int mSavedIcon = 0;
private int mSavedContent = 0;
private Bitmap mSavedLargeIcon;
private String mSavedContentTitle;
private String mCallId = null;
private InCallState mInCallState;
public StatusBarNotifier(Context context, ContactInfoCache contactInfoCache) {
Preconditions.checkNotNull(context);
mContext = context;
mContactInfoCache = contactInfoCache;
mNotificationManager =
(NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
}
/**
* Creates notifications according to the state we receive from {@link InCallPresenter}.
*/
@Override
public void onStateChange(InCallState oldState, InCallState newState, CallList callList) {
Log.d(this, "onStateChange");
mInCallState = newState;
updateNotification(newState, callList);
}
/**
* Updates the phone app's status bar notification *and* launches the
* incoming call UI in response to a new incoming call.
*
* If an incoming call is ringing (or call-waiting), the notification
* will also include a "fullScreenIntent" that will cause the
* InCallScreen to be launched, unless the current foreground activity
* is marked as "immersive".
*
* (This is the mechanism that actually brings up the incoming call UI
* when we receive a "new ringing connection" event from the telephony
* layer.)
*
* Also note that this method is safe to call even if the phone isn't
* actually ringing (or, more likely, if an incoming call *was*
* ringing briefly but then disconnected). In that case, we'll simply
* update or cancel the in-call notification based on the current
* phone state.
*
* @see #updateInCallNotification(InCallState,CallList)
*/
public void updateNotification(InCallState state, CallList callList) {
updateInCallNotification(state, callList);
}
/**
* Take down the in-call notification.
* @see #updateInCallNotification(InCallState,CallList)
*/
private void cancelInCall() {
if (mIsShowingNotification) {
Log.d(this, "cancelInCall()...");
mNotificationManager.cancel(IN_CALL_NOTIFICATION);
}
mIsShowingNotification = false;
}
/* package */ static void clearInCallNotification(Context backupContext) {
Log.i(StatusBarNotifier.class.getSimpleName(),
"Something terrible happened. Clear all InCall notifications");
NotificationManager notificationManager =
(NotificationManager) backupContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(IN_CALL_NOTIFICATION);
}
/**
* Helper method for updateInCallNotification() and
* updateNotification(): Update the phone app's
* status bar notification based on the current telephony state, or
* cancels the notification if the phone is totally idle.
*/
private void updateInCallNotification(final InCallState state, CallList callList) {
Log.d(this, "updateInCallNotification...");
final Call call = getCallToShow(callList);
if (call != null) {
showNotification(call);
} else {
cancelInCall();
}
}
private void showNotification(final Call call) {
final boolean isIncoming = (call.getState() == Call.State.INCOMING ||
call.getState() == Call.State.CALL_WAITING);
if (mCallId != null) {
CallList.getInstance().removeCallUpdateListener(mCallId, this);
}
mCallId = call.getId();
CallList.getInstance().addCallUpdateListener(call.getId(), this);
// we make a call to the contact info cache to query for supplemental data to what the
// call provides. This includes the contact name and photo.
// This callback will always get called immediately and synchronously with whatever data
// it has available, and may make a subsequent call later (same thread) if it had to
// call into the contacts provider for more data.
mContactInfoCache.findInfo(call, isIncoming, new ContactInfoCacheCallback() {
@Override
public void onContactInfoComplete(String callId, ContactCacheEntry entry) {
Call call = CallList.getInstance().getCallById(callId);
if (call != null) {
buildAndSendNotification(call, entry);
}
}
@Override
public void onImageLoadComplete(String callId, ContactCacheEntry entry) {
Call call = CallList.getInstance().getCallById(callId);
if (call != null) {
buildAndSendNotification(call, entry);
}
}
});
}
/**
* Sets up the main Ui for the notification
*/
private void buildAndSendNotification(Call originalCall, ContactCacheEntry contactInfo) {
// This can get called to update an existing notification after contact information has come
// back. However, it can happen much later. Before we continue, we need to make sure that
// the call being passed in is still the one we want to show in the notification.
final Call call = getCallToShow(CallList.getInstance());
if (call == null || !call.getId().equals(originalCall.getId())) {
return;
}
final int state = call.getState();
// Check if data has changed; if nothing is different, don't issue another notification.
final int iconResId = getIconToDisplay(call);
final Bitmap largeIcon = getLargeIconToDisplay(contactInfo, call);
final int contentResId = getContentString(call);
final String contentTitle = getContentTitle(contactInfo, call);
if (!checkForChangeAndSaveData(iconResId, contentResId, largeIcon, contentTitle, state)) {
return;
}
/*
* Nothing more to check...build and send it.
*/
final Notification.Builder builder = getNotificationBuilder();
// Set up the main intent to send the user to the in-call screen
final PendingIntent inCallPendingIntent = createLaunchPendingIntent();
builder.setContentIntent(inCallPendingIntent);
// Set the intent as a full screen intent as well if a call is incoming
if ((state == Call.State.INCOMING || state == Call.State.CALL_WAITING) &&
!InCallPresenter.getInstance().isShowingInCallUi()) {
configureFullScreenIntent(builder, inCallPendingIntent, call);
// Set the notification category for incoming calls
builder.setCategory(Notification.CATEGORY_CALL);
}
// Set the content
builder.setContentText(mContext.getString(contentResId));
builder.setSmallIcon(iconResId);
builder.setContentTitle(contentTitle);
builder.setLargeIcon(largeIcon);
builder.setColor(mContext.getResources().getColor(R.color.dialer_theme_color));
final boolean isVideoUpgradeRequest = call.getSessionModificationState()
== Call.SessionModificationState.RECEIVED_UPGRADE_TO_VIDEO_REQUEST;
if (isVideoUpgradeRequest) {
builder.setUsesChronometer(false);
addDismissUpgradeRequestAction(builder);
addAcceptUpgradeRequestAction(builder);
} else {
createIncomingCallNotification(call, state, builder);
}
addPersonReference(builder, contactInfo, call);
/*
* Fire off the notification
*/
Notification notification = builder.build();
Log.d(this, "Notifying IN_CALL_NOTIFICATION: " + notification);
mNotificationManager.notify(IN_CALL_NOTIFICATION, notification);
mIsShowingNotification = true;
}
private void createIncomingCallNotification(
Call call, int state, Notification.Builder builder) {
if (state == Call.State.ACTIVE) {
builder.setUsesChronometer(true);
builder.setWhen(call.getConnectTimeMillis());
} else {
builder.setUsesChronometer(false);
}
// Add hang up option for any active calls (active | onhold), outgoing calls (dialing).
if (state == Call.State.ACTIVE ||
state == Call.State.ONHOLD ||
Call.State.isDialing(state)) {
addHangupAction(builder);
} else if (state == Call.State.INCOMING || state == Call.State.CALL_WAITING) {
addDismissAction(builder);
if (call.isVideoCall(mContext)) {
addVoiceAction(builder);
addVideoCallAction(builder);
} else {
addAnswerAction(builder);
}
}
}
/**
* Checks the new notification data and compares it against any notification that we
* are already displaying. If the data is exactly the same, we return false so that
* we do not issue a new notification for the exact same data.
*/
private boolean checkForChangeAndSaveData(int icon, int content, Bitmap largeIcon,
String contentTitle, int state) {
// The two are different:
// if new title is not null, it should be different from saved version OR
// if new title is null, the saved version should not be null
final boolean contentTitleChanged =
(contentTitle != null && !contentTitle.equals(mSavedContentTitle)) ||
(contentTitle == null && mSavedContentTitle != null);
// any change means we are definitely updating
boolean retval = (mSavedIcon != icon) || (mSavedContent != content) ||
(mCallState != state) || (mSavedLargeIcon != largeIcon) ||
contentTitleChanged;
// If we aren't showing a notification right now, definitely start showing one.
if (!mIsShowingNotification) {
Log.d(this, "Showing notification for first time.");
retval = true;
}
mSavedIcon = icon;
mSavedContent = content;
mCallState = state;
mSavedLargeIcon = largeIcon;
mSavedContentTitle = contentTitle;
if (retval) {
Log.d(this, "Data changed. Showing notification");
}
return retval;
}
/**
* Returns the main string to use in the notification.
*/
private String getContentTitle(ContactCacheEntry contactInfo, Call call) {
if (call.isConferenceCall() && !call.can(Details.CAPABILITY_GENERIC_CONFERENCE)) {
return mContext.getResources().getString(R.string.card_title_conf_call);
}
if (TextUtils.isEmpty(contactInfo.name)) {
return TextUtils.isEmpty(contactInfo.number) ? null
: BidiFormatter.getInstance().unicodeWrap(
contactInfo.number.toString(), TextDirectionHeuristics.LTR);
}
return contactInfo.name;
}
private void addPersonReference(Notification.Builder builder, ContactCacheEntry contactInfo,
Call call) {
if (contactInfo.lookupUri != null) {
builder.addPerson(contactInfo.lookupUri.toString());
} else if (!TextUtils.isEmpty(call.getNumber())) {
builder.addPerson(Uri.fromParts(PhoneAccount.SCHEME_TEL,
call.getNumber(), null).toString());
}
}
/**
* Gets a large icon from the contact info object to display in the notification.
*/
private Bitmap getLargeIconToDisplay(ContactCacheEntry contactInfo, Call call) {
Bitmap largeIcon = null;
if (call.isConferenceCall() && !call.can(Details.CAPABILITY_GENERIC_CONFERENCE)) {
largeIcon = BitmapFactory.decodeResource(mContext.getResources(),
R.drawable.img_conference);
}
if (contactInfo.photo != null && (contactInfo.photo instanceof BitmapDrawable)) {
largeIcon = ((BitmapDrawable) contactInfo.photo).getBitmap();
}
if (largeIcon != null) {
final int height = (int) mContext.getResources().getDimension(
android.R.dimen.notification_large_icon_height);
final int width = (int) mContext.getResources().getDimension(
android.R.dimen.notification_large_icon_width);
largeIcon = BitmapUtil.getRoundedBitmap(largeIcon, width, height);
}
return largeIcon;
}
/**
* Returns the appropriate icon res Id to display based on the call for which
* we want to display information.
*/
private int getIconToDisplay(Call call) {
// Even if both lines are in use, we only show a single item in
// the expanded Notifications UI. It's labeled "Ongoing call"
// (or "On hold" if there's only one call, and it's on hold.)
// Also, we don't have room to display caller-id info from two
// different calls. So if both lines are in use, display info
// from the foreground call. And if there's a ringing call,
// display that regardless of the state of the other calls.
if (call.getState() == Call.State.ONHOLD) {
return R.drawable.ic_phone_paused_white_24dp;
} else if (call.getSessionModificationState()
== Call.SessionModificationState.RECEIVED_UPGRADE_TO_VIDEO_REQUEST) {
return R.drawable.ic_videocam;
}
return R.drawable.ic_call_white_24dp;
}
/**
* Returns the message to use with the notification.
*/
private int getContentString(Call call) {
int resId = R.string.notification_ongoing_call;
if (call.can(Details.CAPABILITY_WIFI)) {
resId = R.string.notification_ongoing_call_wifi;
}
if (call.getState() == Call.State.INCOMING || call.getState() == Call.State.CALL_WAITING) {
if (call.can(Details.CAPABILITY_WIFI)) {
resId = R.string.notification_incoming_call_wifi;
} else {
resId = R.string.notification_incoming_call;
}
} else if (call.getState() == Call.State.ONHOLD) {
resId = R.string.notification_on_hold;
} else if (Call.State.isDialing(call.getState())) {
resId = R.string.notification_dialing;
} else if (call.getSessionModificationState()
== Call.SessionModificationState.RECEIVED_UPGRADE_TO_VIDEO_REQUEST) {
resId = R.string.notification_requesting_video_call;
}
return resId;
}
/**
* Gets the most relevant call to display in the notification.
*/
private Call getCallToShow(CallList callList) {
if (callList == null) {
return null;
}
Call call = callList.getIncomingCall();
if (call == null) {
call = callList.getOutgoingCall();
}
if (call == null) {
call = callList.getVideoUpgradeRequestCall();
}
if (call == null) {
call = callList.getActiveOrBackgroundCall();
}
return call;
}
private void addAnswerAction(Notification.Builder builder) {
Log.i(this, "Will show \"answer\" action in the incoming call Notification");
PendingIntent answerVoicePendingIntent = createNotificationPendingIntent(
mContext, InCallApp.ACTION_ANSWER_VOICE_INCOMING_CALL);
builder.addAction(R.drawable.ic_call_white_24dp,
mContext.getText(R.string.notification_action_answer),
answerVoicePendingIntent);
}
private void addDismissAction(Notification.Builder builder) {
Log.i(this, "Will show \"dismiss\" action in the incoming call Notification");
PendingIntent declinePendingIntent =
createNotificationPendingIntent(mContext, InCallApp.ACTION_DECLINE_INCOMING_CALL);
builder.addAction(R.drawable.ic_close_dk,
mContext.getText(R.string.notification_action_dismiss),
declinePendingIntent);
}
private void addHangupAction(Notification.Builder builder) {
Log.i(this, "Will show \"hang-up\" action in the ongoing active call Notification");
PendingIntent hangupPendingIntent =
createNotificationPendingIntent(mContext, InCallApp.ACTION_HANG_UP_ONGOING_CALL);
builder.addAction(R.drawable.ic_call_end_white_24dp,
mContext.getText(R.string.notification_action_end_call),
hangupPendingIntent);
}
private void addVideoCallAction(Notification.Builder builder) {
Log.i(this, "Will show \"video\" action in the incoming call Notification");
PendingIntent answerVideoPendingIntent = createNotificationPendingIntent(
mContext, InCallApp.ACTION_ANSWER_VIDEO_INCOMING_CALL);
builder.addAction(R.drawable.ic_videocam,
mContext.getText(R.string.notification_action_answer_video),
answerVideoPendingIntent);
}
private void addVoiceAction(Notification.Builder builder) {
Log.i(this, "Will show \"voice\" action in the incoming call Notification");
PendingIntent answerVoicePendingIntent = createNotificationPendingIntent(
mContext, InCallApp.ACTION_ANSWER_VOICE_INCOMING_CALL);
builder.addAction(R.drawable.ic_call_white_24dp,
mContext.getText(R.string.notification_action_answer_voice),
answerVoicePendingIntent);
}
private void addAcceptUpgradeRequestAction(Notification.Builder builder) {
Log.i(this, "Will show \"accept\" action in the incoming call Notification");
PendingIntent acceptVideoPendingIntent = createNotificationPendingIntent(
mContext, InCallApp.ACTION_ACCEPT_VIDEO_UPGRADE_REQUEST);
builder.addAction(0, mContext.getText(R.string.notification_action_accept),
acceptVideoPendingIntent);
}
private void addDismissUpgradeRequestAction(Notification.Builder builder) {
Log.i(this, "Will show \"dismiss\" action in the incoming call Notification");
PendingIntent declineVideoPendingIntent = createNotificationPendingIntent(
mContext, InCallApp.ACTION_DECLINE_VIDEO_UPGRADE_REQUEST);
builder.addAction(0, mContext.getText(R.string.notification_action_dismiss),
declineVideoPendingIntent);
}
/**
* Adds fullscreen intent to the builder.
*/
private void configureFullScreenIntent(Notification.Builder builder, PendingIntent intent,
Call call) {
// Ok, we actually want to launch the incoming call
// UI at this point (in addition to simply posting a notification
// to the status bar). Setting fullScreenIntent will cause
// the InCallScreen to be launched immediately *unless* the
// current foreground activity is marked as "immersive".
Log.d(this, "- Setting fullScreenIntent: " + intent);
builder.setFullScreenIntent(intent, true);
// Ugly hack alert:
//
// The NotificationManager has the (undocumented) behavior
// that it will *ignore* the fullScreenIntent field if you
// post a new Notification that matches the ID of one that's
// already active. Unfortunately this is exactly what happens
// when you get an incoming call-waiting call: the
// "ongoing call" notification is already visible, so the
// InCallScreen won't get launched in this case!
// (The result: if you bail out of the in-call UI while on a
// call and then get a call-waiting call, the incoming call UI
// won't come up automatically.)
//
// The workaround is to just notice this exact case (this is a
// call-waiting call *and* the InCallScreen is not in the
// foreground) and manually cancel the in-call notification
// before (re)posting it.
//
// TODO: there should be a cleaner way of avoiding this
// problem (see discussion in bug 3184149.)
// If a call is onhold during an incoming call, the call actually comes in as
// INCOMING. For that case *and* traditional call-waiting, we want to
// cancel the notification.
boolean isCallWaiting = (call.getState() == Call.State.CALL_WAITING ||
(call.getState() == Call.State.INCOMING &&
CallList.getInstance().getBackgroundCall() != null));
if (isCallWaiting) {
Log.i(this, "updateInCallNotification: call-waiting! force relaunch...");
// Cancel the IN_CALL_NOTIFICATION immediately before
// (re)posting it; this seems to force the
// NotificationManager to launch the fullScreenIntent.
mNotificationManager.cancel(IN_CALL_NOTIFICATION);
}
}
private Notification.Builder getNotificationBuilder() {
final Notification.Builder builder = new Notification.Builder(mContext);
builder.setOngoing(true);
// Make the notification prioritized over the other normal notifications.
builder.setPriority(Notification.PRIORITY_HIGH);
return builder;
}
private PendingIntent createLaunchPendingIntent() {
final Intent intent = InCallPresenter.getInstance().getInCallIntent(
false /* showDialpad */, false /* newOutgoingCall */,
false /* showCircularReveal */);
// PendingIntent that can be used to launch the InCallActivity. The
// system fires off this intent if the user pulls down the windowshade
// and clicks the notification's expanded view. It's also used to
// launch the InCallActivity immediately when when there's an incoming
// call (see the "fullScreenIntent" field below).
PendingIntent inCallPendingIntent = PendingIntent.getActivity(mContext, 0, intent, 0);
return inCallPendingIntent;
}
/**
* Returns PendingIntent for answering a phone call. This will typically be used from
* Notification context.
*/
private static PendingIntent createNotificationPendingIntent(Context context, String action) {
final Intent intent = new Intent(action, null,
context, NotificationBroadcastReceiver.class);
return PendingIntent.getBroadcast(context, 0, intent, 0);
}
@Override
public void onCallChanged(Call call) {
// no-op
}
/**
* Responds to changes in the session modification state for the call by dismissing the
* status bar notification as required.
*
* @param sessionModificationState The new session modification state.
*/
@Override
public void onSessionModificationStateChange(int sessionModificationState) {
if (sessionModificationState == Call.SessionModificationState.NO_REQUEST) {
if (mCallId != null) {
CallList.getInstance().removeCallUpdateListener(mCallId, this);
}
updateNotification(mInCallState, CallList.getInstance());
}
}
}
|