/* * Copyright (C) 2019 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.server.telecom.ui; import android.annotation.NonNull; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.TaskStackBuilder; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Binder; import android.os.UserHandle; import android.provider.CallLog; import android.telecom.DisconnectCause; import android.telecom.Log; import android.telecom.PhoneAccount; import android.telephony.PhoneNumberUtils; import android.telephony.TelephonyManager; import android.text.BidiFormatter; import android.text.TextDirectionHeuristics; import android.text.TextUtils; import com.android.server.telecom.Call; import com.android.server.telecom.CallState; import com.android.server.telecom.CallsManager; import com.android.server.telecom.CallsManagerListenerBase; import com.android.server.telecom.Constants; import com.android.server.telecom.R; import com.android.server.telecom.TelecomBroadcastIntentProcessor; import com.android.server.telecom.components.TelecomBroadcastReceiver; import java.util.Locale; /** * Handles notifications generated by Telecom for the case that a call was disconnected in order to * connect another "higher priority" emergency call and gives the user the choice to call or * message that user back after, similar to the missed call notifier. */ public class DisconnectedCallNotifier extends CallsManagerListenerBase { public interface Factory { DisconnectedCallNotifier create(Context context, CallsManager manager); } public static class Default implements Factory { @Override public DisconnectedCallNotifier create(Context context, CallsManager manager) { return new DisconnectedCallNotifier(context, manager); } } private static class CallInfo { public final UserHandle userHandle; public final Uri handle; public final long endTimeMs; public final Bitmap callerInfoIcon; public final Drawable callerInfoPhoto; public final String callerInfoName; public final boolean isEmergency; public CallInfo(UserHandle userHandle, Uri handle, long endTimeMs, Bitmap callerInfoIcon, Drawable callerInfoPhoto, String callerInfoName, boolean isEmergency) { this.userHandle = userHandle; this.handle = handle; this.endTimeMs = endTimeMs; this.callerInfoIcon = callerInfoIcon; this.callerInfoPhoto = callerInfoPhoto; this.callerInfoName = callerInfoName; this.isEmergency = isEmergency; } @Override public String toString() { return "CallInfo{" + "userHandle=" + userHandle + ", handle=" + handle + ", isEmergency=" + isEmergency + ", endTimeMs=" + endTimeMs + ", callerInfoIcon=" + callerInfoIcon + ", callerInfoPhoto=" + callerInfoPhoto + ", callerInfoName='" + callerInfoName + '\'' + '}'; } } private static final String NOTIFICATION_TAG = DisconnectedCallNotifier.class.getSimpleName(); private static final int DISCONNECTED_CALL_NOTIFICATION_ID = 1; private final Context mContext; private final CallsManager mCallsManager; private final NotificationManager mNotificationManager; // The pending info to display to the user after they have ended the emergency call. private CallInfo mPendingCallNotification; public DisconnectedCallNotifier(Context context, CallsManager callsManager) { mContext = context; mNotificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); mCallsManager = callsManager; } @Override public void onCallRemoved(Call call) { // Wait until the emergency call is ended before showing the notification. if (mCallsManager.getCalls().isEmpty() && mPendingCallNotification != null) { showDisconnectedNotification(mPendingCallNotification); mPendingCallNotification = null; } } @Override public void onCallStateChanged(Call call, int oldState, int newState) { DisconnectCause cause = call.getDisconnectCause(); if (cause == null) { Log.w(this, "onCallStateChanged: unexpected null disconnect cause."); return; } // Call disconnected in favor of an emergency call. Place the call into a pending queue. if ((newState == CallState.DISCONNECTED) && (cause.getCode() == DisconnectCause.LOCAL) && DisconnectCause.REASON_EMERGENCY_CALL_PLACED.equals(cause.getReason())) { // Clear any existing notification. clearNotification(mCallsManager.getCurrentUserHandle()); UserHandle userHandle = call.getTargetPhoneAccount() != null ? call.getTargetPhoneAccount().getUserHandle() : call.getInitiatingUser(); // As a last resort, use the current user to display the notification. if (userHandle == null) userHandle = mCallsManager.getCurrentUserHandle(); mPendingCallNotification = new CallInfo(userHandle, call.getHandle(), call.getCreationTimeMillis() + call.getAgeMillis(), call.getPhotoIcon(), call.getPhoto(), call.getName(), call.isEmergencyCall()); } } private void showDisconnectedNotification(@NonNull CallInfo call) { Log.i(this, "showDisconnectedNotification: userHandle=%d", call.userHandle.getIdentifier()); final int titleResId = R.string.notification_disconnectedCall_title; final CharSequence expandedText = call.isEmergency ? mContext.getText(R.string.notification_disconnectedCall_generic_body) : mContext.getString(R.string.notification_disconnectedCall_body, getNameForCallNotification(call)); // Create a public viewable version of the notification, suitable for display when sensitive // notification content is hidden. // We use user's context here to make sure notification is badged if it is a managed user. Context contextForUser = getContextForUser(call.userHandle); Notification.Builder publicBuilder = new Notification.Builder(contextForUser, NotificationChannelManager.CHANNEL_ID_DISCONNECTED_CALLS); publicBuilder.setSmallIcon(android.R.drawable.stat_notify_error) .setColor(mContext.getResources().getColor(R.color.theme_color, null /*theme*/)) // Set when the call was disconnected. .setWhen(call.endTimeMs) .setShowWhen(true) // Show "Phone" for notification title. .setContentTitle(mContext.getText(R.string.userCallActivityLabel)) // Notification details shows that there are disconnected call(s), but does not // reveal the caller information. .setContentText(mContext.getText(titleResId)) .setAutoCancel(true); if (!call.isEmergency) { publicBuilder.setContentIntent(createCallLogPendingIntent(call.userHandle)); } // Create the notification suitable for display when sensitive information is showing. Notification.Builder builder = new Notification.Builder(contextForUser, NotificationChannelManager.CHANNEL_ID_DISCONNECTED_CALLS); builder.setSmallIcon(android.R.drawable.stat_notify_error) .setColor(mContext.getResources().getColor(R.color.theme_color, null /*theme*/)) .setWhen(call.endTimeMs) .setShowWhen(true) .setContentTitle(mContext.getText(titleResId)) //Only show expanded text for sensitive information .setStyle(new Notification.BigTextStyle().bigText(expandedText)) .setAutoCancel(true) // Include a public version of the notification to be shown when the call // notification is shown on the user's lock screen and they have chosen to hide // sensitive notification information. .setPublicVersion(publicBuilder.build()) .setChannelId(NotificationChannelManager.CHANNEL_ID_DISCONNECTED_CALLS); if (!call.isEmergency) { builder.setContentIntent(createCallLogPendingIntent(call.userHandle)); } String handle = call.handle != null ? call.handle.getSchemeSpecificPart() : null; if (!TextUtils.isEmpty(handle) && !TextUtils.equals(handle, mContext.getString(R.string.handle_restricted)) && !call.isEmergency) { builder.addAction(new Notification.Action.Builder( Icon.createWithResource(contextForUser, R.drawable.ic_phone_24dp), // Reuse missed call "Call back" mContext.getString(R.string.notification_missedCall_call_back), createCallBackPendingIntent(call.handle, call.userHandle)).build()); if (canRespondViaSms(call)) { builder.addAction(new Notification.Action.Builder( Icon.createWithResource(contextForUser, R.drawable.ic_message_24dp), // Reuse missed call "Call back" mContext.getString(R.string.notification_missedCall_message), createSendSmsFromNotificationPendingIntent(call.handle, call.userHandle)).build()); } } if (call.callerInfoIcon != null) { builder.setLargeIcon(call.callerInfoIcon); } else { if (call.callerInfoPhoto instanceof BitmapDrawable) { builder.setLargeIcon(((BitmapDrawable) call.callerInfoPhoto).getBitmap()); } } Notification notification = builder.build(); Log.i(this, "Adding missed call notification for %s.", Log.pii(call.handle)); long token = Binder.clearCallingIdentity(); try { // TODO: Only support one notification right now, so if multiple are hung up, we only // show the last one. Support multiple in the future. mNotificationManager.notifyAsUser(NOTIFICATION_TAG, DISCONNECTED_CALL_NOTIFICATION_ID, notification, call.userHandle); } finally { Binder.restoreCallingIdentity(token); } } /** * Returns the name to use in the call notification. */ private String getNameForCallNotification(@NonNull CallInfo call) { String number = call.handle != null ? call.handle.getSchemeSpecificPart() : null; if (!TextUtils.isEmpty(number)) { String formattedNumber = PhoneNumberUtils.formatNumber(number, getCurrentCountryIso(mContext)); // The formatted number will be null if there was a problem formatting it, but we can // default to using the unformatted number instead (e.g. a SIP URI may not be able to // be formatted. if (!TextUtils.isEmpty(formattedNumber)) { number = formattedNumber; } } if (!TextUtils.isEmpty(call.callerInfoName) && TextUtils.isGraphic(call.callerInfoName)) { return call.callerInfoName; } if (!TextUtils.isEmpty(number)) { // A handle should always be displayed LTR using {@link BidiFormatter} regardless of the // content of the rest of the notification. // TODO: Does this apply to SIP addresses? BidiFormatter bidiFormatter = BidiFormatter.getInstance(); return bidiFormatter.unicodeWrap(number, TextDirectionHeuristics.LTR); } else { // Use "unknown" if the call is unidentifiable. return mContext.getString(R.string.unknown); } } /** * @return The ISO 3166-1 two letters country code of the country the user is in based on the * network location. If the network location does not exist, fall back to the locale * setting. */ private String getCurrentCountryIso(Context context) { // Without framework function calls, this seems to be the most accurate location service // we can rely on. final TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); String countryIso = telephonyManager.getNetworkCountryIso().toUpperCase(); if (countryIso == null) { countryIso = Locale.getDefault().getCountry(); Log.w(this, "No CountryDetector; falling back to countryIso based on locale: " + countryIso); } return countryIso; } private Context getContextForUser(UserHandle user) { try { return mContext.createPackageContextAsUser(mContext.getPackageName(), 0, user); } catch (PackageManager.NameNotFoundException e) { // Default to mContext, not finding the package system is running as is unlikely. return mContext; } } /** * Creates an intent to be invoked when the user opts to "call back" from the disconnected call * notification. * * @param handle The handle to call back. */ private PendingIntent createCallBackPendingIntent(Uri handle, UserHandle userHandle) { return createTelecomPendingIntent( TelecomBroadcastIntentProcessor.ACTION_DISCONNECTED_CALL_BACK_FROM_NOTIFICATION, handle, userHandle); } /** * Creates generic pending intent from the specified parameters to be received by * {@link TelecomBroadcastIntentProcessor}. * * @param action The intent action. * @param data The intent data. */ private PendingIntent createTelecomPendingIntent(String action, Uri data, UserHandle userHandle) { Intent intent = new Intent(action, data, mContext, TelecomBroadcastReceiver.class); intent.putExtra(TelecomBroadcastIntentProcessor.EXTRA_USERHANDLE, userHandle); return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } private boolean canRespondViaSms(@NonNull CallInfo call) { // Only allow respond-via-sms for "tel:" calls. return call.handle != null && PhoneAccount.SCHEME_TEL.equals(call.handle.getScheme()); } /** * Creates a new pending intent that sends the user to the call log. * * @return The pending intent. */ private PendingIntent createCallLogPendingIntent(UserHandle userHandle) { Intent intent = new Intent(Intent.ACTION_VIEW, null); intent.setType(CallLog.Calls.CONTENT_TYPE); TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(mContext); taskStackBuilder.addNextIntent(intent); return taskStackBuilder.getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE, null, userHandle); } /** * Creates an intent to be invoked when the user opts to "send sms" from the missed call * notification. */ private PendingIntent createSendSmsFromNotificationPendingIntent(Uri handle, UserHandle userHandle) { return createTelecomPendingIntent( TelecomBroadcastIntentProcessor.ACTION_DISCONNECTED_SEND_SMS_FROM_NOTIFICATION, Uri.fromParts(Constants.SCHEME_SMSTO, handle.getSchemeSpecificPart(), null), userHandle); } /** * Clear any of the active notifications. * @param userHandle The user to clear the notifications for. */ public void clearNotification(UserHandle userHandle) { long token = Binder.clearCallingIdentity(); try { mNotificationManager.cancelAsUser(NOTIFICATION_TAG, DISCONNECTED_CALL_NOTIFICATION_ID, userHandle); } finally { Binder.restoreCallingIdentity(token); } } }