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