1 /* 2 * Copyright (C) 2023 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 17 package com.android.server.telecom.ui; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.app.Notification; 22 import android.app.NotificationManager; 23 import android.app.PendingIntent; 24 import android.app.Person; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.graphics.Bitmap; 28 import android.graphics.Canvas; 29 import android.graphics.drawable.BitmapDrawable; 30 import android.graphics.drawable.Drawable; 31 import android.graphics.drawable.Icon; 32 import android.net.Uri; 33 import android.os.UserHandle; 34 import android.telecom.Log; 35 import android.telecom.PhoneAccount; 36 import android.text.Spannable; 37 import android.text.SpannableString; 38 import android.text.TextUtils; 39 import android.text.style.ForegroundColorSpan; 40 import androidx.core.graphics.drawable.RoundedBitmapDrawable; 41 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; 42 43 import com.android.internal.annotations.GuardedBy; 44 import com.android.server.telecom.AppLabelProxy; 45 import com.android.server.telecom.Call; 46 import com.android.server.telecom.CallsManagerListenerBase; 47 import com.android.server.telecom.R; 48 import com.android.server.telecom.TelecomBroadcastIntentProcessor; 49 import com.android.server.telecom.components.TelecomBroadcastReceiver; 50 51 import java.util.concurrent.Executor; 52 53 /** 54 * Class responsible for tracking if there is a call which is being streamed and posting a 55 * notification which informs the user that a call is streaming. The user has two possible actions: 56 * disconnect the call, bring the call back to the current device (stop streaming). 57 */ 58 public class CallStreamingNotification extends CallsManagerListenerBase implements Call.Listener { 59 // URI scheme used for data related to the notification actions. 60 public static final String CALL_ID_SCHEME = "callid"; 61 // The default streaming notification ID. 62 private static final int STREAMING_NOTIFICATION_ID = 90210; 63 // Tag for streaming notification. 64 private static final String NOTIFICATION_TAG = 65 CallStreamingNotification.class.getSimpleName(); 66 67 private final Context mContext; 68 private final NotificationManager mNotificationManager; 69 // Used to get the app name for the notification. 70 private final AppLabelProxy mAppLabelProxy; 71 // An executor that can be used to fire off async tasks that do not block Telecom in any manner. 72 private final Executor mAsyncTaskExecutor; 73 // The call which is streaming. 74 private Call mStreamingCall; 75 // Lock for notification post/remove -- these happen outside the Telecom sync lock. 76 private final Object mNotificationLock = new Object(); 77 78 // Whether the notification is showing. 79 @GuardedBy("mNotificationLock") 80 private boolean mIsNotificationShowing = false; 81 @GuardedBy("mNotificationLock") 82 private UserHandle mNotificationUserHandle; 83 CallStreamingNotification(@onNull Context context, @NonNull AppLabelProxy appLabelProxy, @NonNull Executor asyncTaskExecutor)84 public CallStreamingNotification(@NonNull Context context, 85 @NonNull AppLabelProxy appLabelProxy, 86 @NonNull Executor asyncTaskExecutor) { 87 mContext = context; 88 mNotificationManager = context.getSystemService(NotificationManager.class); 89 mAppLabelProxy = appLabelProxy; 90 mAsyncTaskExecutor = asyncTaskExecutor; 91 } 92 93 @Override onCallAdded(Call call)94 public void onCallAdded(Call call) { 95 if (call.isStreaming()) { 96 trackStreamingCall(call); 97 enqueueStreamingNotification(call); 98 } 99 } 100 101 @Override onCallRemoved(Call call)102 public void onCallRemoved(Call call) { 103 if (call == mStreamingCall) { 104 trackStreamingCall(null); 105 dequeueStreamingNotification(); 106 } 107 } 108 109 /** 110 * Handles streaming state changes for a call. 111 * @param call the call 112 * @param isStreaming whether it is streaming or not 113 */ 114 @Override onCallStreamingStateChanged(Call call, boolean isStreaming)115 public void onCallStreamingStateChanged(Call call, boolean isStreaming) { 116 Log.i(this, "onCallStreamingStateChanged: call=%s, isStreaming=%b", call.getId(), 117 isStreaming); 118 119 if (isStreaming) { 120 trackStreamingCall(call); 121 enqueueStreamingNotification(call); 122 } else { 123 trackStreamingCall(null); 124 dequeueStreamingNotification(); 125 } 126 } 127 128 /** 129 * Change the streaming call we are tracking. 130 * @param call the call. 131 */ trackStreamingCall(Call call)132 private void trackStreamingCall(Call call) { 133 if (mStreamingCall != null) { 134 mStreamingCall.removeListener(this); 135 } 136 mStreamingCall = call; 137 if (mStreamingCall != null) { 138 mStreamingCall.addListener(this); 139 } 140 } 141 142 /** 143 * Enqueue an async task to post/repost the streaming notification. 144 * Note: This happens INSIDE the telecom lock. 145 * @param call the call to post notification for. 146 */ enqueueStreamingNotification(Call call)147 private void enqueueStreamingNotification(Call call) { 148 final Bitmap contactPhotoBitmap = call.getPhotoIcon(); 149 mAsyncTaskExecutor.execute(() -> { 150 Icon contactPhotoIcon = null; 151 try { 152 contactPhotoIcon = Icon.createWithResource(mContext.getResources(), 153 R.drawable.person_circle); 154 } catch (Exception e) { 155 // All loads of things can do wrong when working with bitmaps and images, so to 156 // ensure Telecom doesn't crash, lets try/catch to be sure. 157 Log.e(this, e, "enqueueStreamingNotification: Couldn't build avatar icon"); 158 } 159 showStreamingNotification(call.getId(), 160 call.getAssociatedUser(), call.getCallerDisplayName(), 161 call.getHandle(), contactPhotoIcon, 162 call.getTargetPhoneAccount().getComponentName().getPackageName(), 163 call.getConnectTimeMillis()); 164 }); 165 } 166 167 /** 168 * Dequeues the call streaming notification. 169 * Note: This is yo be called within the Telecom sync lock to launch the task to remove the call 170 * streaming notification. 171 */ dequeueStreamingNotification()172 private void dequeueStreamingNotification() { 173 mAsyncTaskExecutor.execute(() -> hideStreamingNotification()); 174 } 175 176 /** 177 * Show the call streaming notification. This is intended to run outside the Telecom sync lock. 178 * 179 * @param callId the call ID we're streaming. 180 * @param userHandle the userhandle for the call. 181 * @param callerName the name of the caller/callee associated with the call 182 * @param callerAddress the address associated with the caller/callee 183 * @param photoIcon the contact photo icon if available 184 * @param appPackageName the package name for the app to post the notification for 185 * @param connectTimeMillis when the call connected (for chronometer in the notification) 186 */ showStreamingNotification(final String callId, final UserHandle userHandle, String callerName, Uri callerAddress, Icon photoIcon, String appPackageName, long connectTimeMillis)187 private void showStreamingNotification(final String callId, final UserHandle userHandle, 188 String callerName, Uri callerAddress, Icon photoIcon, String appPackageName, 189 long connectTimeMillis) { 190 Log.i(this, "showStreamingNotification; callid=%s, hasPhoto=%b", callId, photoIcon != null); 191 192 // Use the caller name for the label if available, default to app name if none. 193 if (TextUtils.isEmpty(callerName)) { 194 // App did not provide a caller name, so default to app's name. 195 callerName = mAppLabelProxy.getAppLabel(appPackageName).toString(); 196 } 197 198 // Action to hangup; this can use the default hangup action from the call style 199 // notification. 200 Intent hangupIntent = new Intent(TelecomBroadcastIntentProcessor.ACTION_HANGUP_CALL, 201 Uri.fromParts(CALL_ID_SCHEME, callId, null), 202 mContext, TelecomBroadcastReceiver.class); 203 PendingIntent hangupPendingIntent = PendingIntent.getBroadcast(mContext, 0, hangupIntent, 204 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 205 206 // Action to switch here. 207 Intent switchHereIntent = new Intent(TelecomBroadcastIntentProcessor.ACTION_STOP_STREAMING, 208 Uri.fromParts(CALL_ID_SCHEME, callId, null), 209 mContext, TelecomBroadcastReceiver.class); 210 PendingIntent switchHerePendingIntent = PendingIntent.getBroadcast(mContext, 0, 211 switchHereIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 212 213 // Apply a span to the string to colorize it using the "answer" color. 214 Spannable spannable = new SpannableString( 215 mContext.getString(R.string.call_streaming_notification_action_switch_here)); 216 spannable.setSpan(new ForegroundColorSpan( 217 com.android.internal.R.color.call_notification_answer_color), 0, spannable.length(), 218 Spannable.SPAN_INCLUSIVE_EXCLUSIVE); 219 220 // Use the "phone link" icon per mock. 221 Icon switchHereIcon = Icon.createWithResource(mContext, R.drawable.gm_phonelink); 222 Notification.Action.Builder switchHereBuilder = new Notification.Action.Builder( 223 switchHereIcon, 224 spannable, 225 switchHerePendingIntent); 226 Notification.Action switchHereAction = switchHereBuilder.build(); 227 228 // Notifications use a "person" entity to identify caller/callee. 229 Person.Builder personBuilder = new Person.Builder() 230 .setName(callerName); 231 232 // Some apps use phone numbers to identify; these are something the notification framework 233 // can lookup in contacts to provide more data 234 if (callerAddress != null && PhoneAccount.SCHEME_TEL.equals(callerAddress)) { 235 personBuilder.setUri(callerAddress.toString()); 236 } 237 if (photoIcon != null) { 238 personBuilder.setIcon(photoIcon); 239 } 240 Person person = personBuilder.build(); 241 242 // Call Style notification requires a full screen intent, so we'll just link in a null 243 // pending intent 244 Intent nullIntent = new Intent(); 245 PendingIntent nullPendingIntent = PendingIntent.getBroadcast(mContext, 0, nullIntent, 246 PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 247 248 Notification.Builder builder = new Notification.Builder(mContext, 249 NotificationChannelManager.CHANNEL_ID_CALL_STREAMING) 250 // Use call style to get the general look and feel for the notification; it provides 251 // a hangup action with the right action already so we can leverage that. The 252 // "switch here" action will be a custom action defined later. 253 .setStyle(Notification.CallStyle.forOngoingCall(person, hangupPendingIntent)) 254 .setSmallIcon(R.drawable.ic_phone) 255 .setContentText(mContext.getString( 256 R.string.call_streaming_notification_body)) 257 // Report call time 258 .setWhen(connectTimeMillis) 259 .setShowWhen(true) 260 .setUsesChronometer(true) 261 // Set the full screen intent; this is just tricking notification manager into 262 // letting us use this style. Sssh. 263 .setFullScreenIntent(nullPendingIntent, true) 264 .setColorized(true) 265 .addAction(switchHereAction); 266 Notification notification = builder.build(); 267 268 synchronized(mNotificationLock) { 269 mIsNotificationShowing = true; 270 mNotificationUserHandle = userHandle; 271 try { 272 mNotificationManager.notifyAsUser(NOTIFICATION_TAG, STREAMING_NOTIFICATION_ID, 273 notification, userHandle); 274 } catch (Exception e) { 275 // We don't want to crash Telecom if something changes with the requirements for the 276 // notification. 277 Log.e(this, e, "Notification post failed."); 278 } 279 } 280 } 281 282 /** 283 * Removes the posted streaming notification. Intended to run outside the telecom lock. 284 */ hideStreamingNotification()285 private void hideStreamingNotification() { 286 Log.i(this, "hideStreamingNotification"); 287 synchronized(mNotificationLock) { 288 if (mIsNotificationShowing) { 289 mIsNotificationShowing = false; 290 mNotificationManager.cancelAsUser(NOTIFICATION_TAG, 291 STREAMING_NOTIFICATION_ID, mNotificationUserHandle); 292 } 293 } 294 } 295 drawableToBitmap(@ullable Drawable drawable, int width, int height)296 public static Bitmap drawableToBitmap(@Nullable Drawable drawable, int width, int height) { 297 if (drawable == null) { 298 return null; 299 } 300 301 Bitmap bitmap; 302 if (drawable instanceof BitmapDrawable) { 303 bitmap = ((BitmapDrawable) drawable).getBitmap(); 304 } else { 305 if (width > 0 || height > 0) { 306 bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 307 } else if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { 308 // Needed for drawables that are just a colour. 309 bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); 310 } else { 311 bitmap = 312 Bitmap.createBitmap( 313 drawable.getIntrinsicWidth(), 314 drawable.getIntrinsicHeight(), 315 Bitmap.Config.ARGB_8888); 316 } 317 318 Canvas canvas = new Canvas(bitmap); 319 drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); 320 drawable.draw(canvas); 321 } 322 return bitmap; 323 } 324 } 325