Implement call streaming notification.
- Fixing some issues with CallStreamingController:
1. onCallStreamingStopped was not called when streaming stops.
2. added missing break for some cases statements.
- Fix bug where transactional calls don't set the provided display name
on the call. This meant that the caller name wouldn't show up on BT, or
in this case the call streaming notification.
- Added new CallStreamingNotification class; this just listens for calls
that are streaming and posts or removes a call streaming notification.
- Added code in TelecomBroadcastIntentProcessor to handle hangup and stop
streaming from the call streaming notification.
- Fix bug in transactional test app which meant that all calls would be
treated as incoming.
- Add a call streaming test app which implements a bare minimum streaming
service for test purposes.
Test: Manual testing using test app.
Bug: 277232336
Change-Id: Id09ba876bc958e5f4f0f560186035f293ba4dfc5
diff --git a/Android.bp b/Android.bp
index c5141ca..501b438 100644
--- a/Android.bp
+++ b/Android.bp
@@ -27,6 +27,7 @@
],
static_libs: [
"androidx.annotation_annotation",
+ "androidx.core_core",
],
libs: [
"services",
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index d42dcff..ab067d9 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -63,6 +63,8 @@
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS"/>
<uses-permission android:name="android.permission.SUBSTITUTE_NOTIFICATION_APP_NAME"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
+ <uses-permission android:name="android.permission.USE_COLORIZED_NOTIFICATIONS"/>
+ <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="com.android.phone.permission.ACCESS_LAST_KNOWN_CELL_ID"/>
<uses-permission android:name="android.permission.STATUS_BAR_SERVICE" />
diff --git a/res/drawable/gm_phonelink.xml b/res/drawable/gm_phonelink.xml
new file mode 100644
index 0000000..2ffba0e
--- /dev/null
+++ b/res/drawable/gm_phonelink.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="20dp"
+ android:height="20dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="?android:attr/colorControlNormal"
+ android:autoMirrored="true">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M5,6h16L21,4L5,4c-1.1,0 -2,0.9 -2,2v11L1,17v3h11v-3L5,17L5,6zM21,8h-6c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1L22,9c0,-0.55 -0.45,-1 -1,-1zM20,17h-4v-7h4v7z"/>
+</vector>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index d67df4b..ec278f0 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -321,6 +321,10 @@
<string name="notification_channel_disconnected_calls">Disconnected calls</string>
<!-- Notification channel name for a channel containing crashed phone apps service notifications. -->
<string name="notification_channel_in_call_service_crash">Crashed phone apps</string>
+ <!-- Notification channel name for a channel containing notifications related to call streaming.
+ Call streaming is a feature where an app can use another device like a tablet to see and
+ control a call taking place on their phone. -->
+ <string name="notification_channel_call_streaming">Call streaming</string>
<!-- Alert dialog content used to inform the user that placing a new outgoing call will end the
ongoing call in the app "other_app". -->
@@ -395,4 +399,20 @@
<string name="callendpoint_name_streaming">External</string>
<!-- The user-visible name of the unknown new type CallEndpoint -->
<string name="callendpoint_name_unknown">Unknown</string>
+
+ <!-- The content of a notification shown when a call is being streamed to another device.
+ Call streaming is a feature where a user can see and interact with a call from another
+ device like a tablet while the call takes place on their phone. -->
+ <string name="call_streaming_notification_body">Streaming audio to other device</string>
+ <!-- A notification action which is shown when a call is being streamed to another device.
+ Tapping the action will hang up the call.
+ Call streaming is a feature where a user can see and interact with a call from another
+ device like a tablet while the call takes place on their phone. -->
+ <string name="call_streaming_notification_action_hang_up">Hang up</string>
+ <!-- A notification action which is shown when a call is being streamed to another device.
+ Tapping the action will move the call back to the phone from the device it is being
+ streamed to.
+ Call streaming is a feature where a user can see and interact with a call from another
+ device like a tablet while the call takes place on their phone. -->
+ <string name="call_streaming_notification_action_switch_here">Switch here</string>
</resources>
diff --git a/src/com/android/server/telecom/Call.java b/src/com/android/server/telecom/Call.java
index 6ae8834..31c1e29 100644
--- a/src/com/android/server/telecom/Call.java
+++ b/src/com/android/server/telecom/Call.java
@@ -999,6 +999,9 @@
s.append(SimpleDateFormat.getDateTimeInstance().format(new Date(getCreationTimeMillis())));
s.append("]");
s.append(isIncoming() ? "(MT - incoming)" : "(MO - outgoing)");
+ s.append("(User=");
+ s.append(getInitiatingUser());
+ s.append(")");
s.append("\n\t");
PhoneAccountHandle targetPhoneAccountHandle = getTargetPhoneAccount();
diff --git a/src/com/android/server/telecom/CallStreamingController.java b/src/com/android/server/telecom/CallStreamingController.java
index 6276a7d..d90524d 100644
--- a/src/com/android/server/telecom/CallStreamingController.java
+++ b/src/com/android/server/telecom/CallStreamingController.java
@@ -87,6 +87,14 @@
mStreamingCall = null;
mTransactionalServiceWrapper = null;
if (mConnection != null) {
+ // Notify service streaming stopped and then unbind.
+ try {
+ mService.onCallStreamingStopped();
+ } catch (RemoteException e) {
+ // Could not notify stop streaming; we're about to just unbind so this is
+ // unfortunate but not the end of the world.
+ Log.e(this, e, "resetController: failed to notify stop streaming.");
+ }
mContext.unbindService(mConnection);
mConnection = null;
}
@@ -140,7 +148,7 @@
@Override
public CompletableFuture<VoipCallTransactionResult> processTransaction(Void v) {
- Log.d(this, "processTransaction");
+ Log.i(this, "processTransaction");
CompletableFuture<VoipCallTransactionResult> future = new CompletableFuture<>();
if (mEnterInterception) {
@@ -178,9 +186,8 @@
@SuppressLint("LongLogTag")
@Override
public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
- Log.d(this, "processTransaction");
+ Log.i(this, "processTransaction");
CompletableFuture<VoipCallTransactionResult> future = new CompletableFuture<>();
-
RoleManager roleManager = mContext.getSystemService(RoleManager.class);
PackageManager packageManager = mContext.getPackageManager();
if (roleManager == null || packageManager == null) {
@@ -198,7 +205,7 @@
VoipCallTransactionResult.RESULT_FAILED, MESSAGE));
return future;
}
-
+ Log.i(this, "processTransaction: servicePackage=%s", holders.get(0));
Intent serviceIntent = new Intent(CallStreamingService.SERVICE_INTERFACE);
serviceIntent.setPackage(holders.get(0));
List<ResolveInfo> infos = packageManager.queryIntentServicesAsUser(serviceIntent,
@@ -223,7 +230,7 @@
Intent intent = new Intent(CallStreamingService.SERVICE_INTERFACE);
intent.setComponent(serviceInfo.getComponentName());
- mConnection = new CallStreamingServiceConnection(mCall, mWrapper, future);
+ mConnection = new CallStreamingServiceConnection(mCall, mWrapper, future);
if (!mContext.bindServiceAsUser(intent, mConnection, Context.BIND_AUTO_CREATE
| Context.BIND_FOREGROUND_SERVICE
| Context.BIND_SCHEDULE_LIKE_TOP_APP, mUserHandle)) {
@@ -232,7 +239,6 @@
VoipCallTransactionResult.RESULT_FAILED,
"STREAMING_FAILED_SENDER_BINDING_ERROR"));
}
-
return future;
}
}
@@ -249,7 +255,7 @@
@SuppressLint("LongLogTag")
@Override
public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
- Log.d(this, "processTransaction");
+ Log.i(this, "processTransaction (unbindStreaming");
CompletableFuture<VoipCallTransactionResult> future = new CompletableFuture<>();
resetController();
@@ -280,11 +286,13 @@
case CallState.ON_HOLD:
transaction = new CallStreamingStateChangeTransaction(
StreamingCall.STATE_HOLDING);
+ break;
case CallState.DISCONNECTING:
case CallState.DISCONNECTED:
Log.addEvent(call, LogUtils.Events.STOP_STREAMING);
transaction = new CallStreamingStateChangeTransaction(
StreamingCall.STATE_DISCONNECTED);
+ break;
default:
// ignore
}
@@ -374,13 +382,6 @@
}
private void clearBinding() {
- try {
- if (mService != null) {
- mService.onCallStreamingStopped();
- }
- } catch (RemoteException e) {
- Log.e(this, e, "Exception when stop call streaming");
- }
resetController();
if (!mFuture.isDone()) {
mFuture.complete(new VoipCallTransactionResult(
diff --git a/src/com/android/server/telecom/CallsManager.java b/src/com/android/server/telecom/CallsManager.java
index 555ac42..0a82c4f 100644
--- a/src/com/android/server/telecom/CallsManager.java
+++ b/src/com/android/server/telecom/CallsManager.java
@@ -41,6 +41,7 @@
import android.Manifest;
import android.annotation.NonNull;
+import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.app.KeyguardManager;
@@ -132,6 +133,7 @@
import com.android.server.telecom.stats.CallFailureCause;
import com.android.server.telecom.ui.AudioProcessingNotification;
import com.android.server.telecom.ui.CallRedirectionTimeoutDialogActivity;
+import com.android.server.telecom.ui.CallStreamingNotification;
import com.android.server.telecom.ui.ConfirmCallDialogActivity;
import com.android.server.telecom.ui.DisconnectedCallNotifier;
import com.android.server.telecom.ui.IncomingCallNotifier;
@@ -448,6 +450,7 @@
private final BlockedNumbersAdapter mBlockedNumbersAdapter;
private final TransactionManager mTransactionManager;
private final UserManager mUserManager;
+ private final CallStreamingNotification mCallStreamingNotification;
private final ConnectionServiceFocusManager.CallsManagerRequester mRequester =
new ConnectionServiceFocusManager.CallsManagerRequester() {
@@ -560,7 +563,9 @@
BlockedNumbersAdapter blockedNumbersAdapter,
TransactionManager transactionManager,
EmergencyCallDiagnosticLogger emergencyCallDiagnosticLogger,
- CallAudioCommunicationDeviceTracker communicationDeviceTracker) {
+ CallAudioCommunicationDeviceTracker communicationDeviceTracker,
+ CallStreamingNotification callStreamingNotification) {
+
mContext = context;
mLock = lock;
mPhoneNumberUtilsAdapter = phoneNumberUtilsAdapter;
@@ -649,6 +654,7 @@
mTransactionManager = transactionManager;
mBlockedNumbersAdapter = blockedNumbersAdapter;
mCallStreamingController = new CallStreamingController(mContext, mLock);
+ mCallStreamingNotification = callStreamingNotification;
mListeners.add(mInCallWakeLockController);
mListeners.add(statusBarNotifier);
@@ -670,6 +676,7 @@
// this needs to be after the mCallAudioManager
mListeners.add(mPhoneStateBroadcaster);
mListeners.add(mVoipCallMonitor);
+ mListeners.add(mCallStreamingNotification);
mVoipCallMonitor.startMonitor();
@@ -1415,6 +1422,15 @@
extras.getInt(CallAttributes.CALL_CAPABILITIES_KEY,
CallAttributes.SUPPORTS_SET_INACTIVE), true);
call.setTargetPhoneAccount(phoneAccountHandle);
+ if (extras.containsKey(CallAttributes.DISPLAY_NAME_KEY)) {
+ CharSequence displayName = extras.getCharSequence(CallAttributes.DISPLAY_NAME_KEY);
+ if (!TextUtils.isEmpty(displayName)) {
+ call.setCallerDisplayName(displayName.toString(),
+ TelecomManager.PRESENTATION_ALLOWED);
+ }
+ }
+ // Incoming address was set via EXTRA_INCOMING_CALL_ADDRESS above.
+ call.setInitiatingUser(phoneAccountHandle.getUserHandle());
}
// Ensure new calls related to self-managed calls/connections are set as such. This will
@@ -1699,7 +1715,6 @@
boolean isReusedCall;
Uri handle = isConference ? Uri.parse("tel:conf-factory") : participants.get(0);
Call call = reuseOutgoingCall(handle);
-
PhoneAccount account =
mPhoneAccountRegistrar.getPhoneAccount(requestedAccountHandle, initiatingUser);
Bundle phoneAccountExtra = account != null ? account.getExtras() : null;
@@ -1740,6 +1755,14 @@
call.setConnectionCapabilities(
extras.getInt(CallAttributes.CALL_CAPABILITIES_KEY,
CallAttributes.SUPPORTS_SET_INACTIVE), true);
+ if (extras.containsKey(CallAttributes.DISPLAY_NAME_KEY)) {
+ CharSequence displayName = extras.getCharSequence(
+ CallAttributes.DISPLAY_NAME_KEY);
+ if (!TextUtils.isEmpty(displayName)) {
+ call.setCallerDisplayName(displayName.toString(),
+ TelecomManager.PRESENTATION_ALLOWED);
+ }
+ }
call.setTargetPhoneAccount(requestedAccountHandle);
}
@@ -6366,4 +6389,30 @@
public CallStreamingController getCallStreamingController() {
return mCallStreamingController;
}
+
+ /**
+ * Given a call identified by call id, get the instance from the list of calls.
+ * @param callId the call id.
+ * @return the call, or null if not found.
+ */
+ public @Nullable Call getCall(@NonNull String callId) {
+ Optional<Call> foundCall = mCalls.stream().filter(
+ c -> c.getId().equals(callId)).findFirst();
+ if (foundCall.isPresent()) {
+ return foundCall.get();
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Triggers stopping of call streaming for a call by launching a stop streaming transaction.
+ * @param call the call.
+ */
+ public void stopCallStreaming(@NonNull Call call) {
+ if (call.getTransactionServiceWrapper() == null) {
+ return;
+ }
+ call.getTransactionServiceWrapper().stopCallStreaming(call);
+ }
}
diff --git a/src/com/android/server/telecom/TelecomBroadcastIntentProcessor.java b/src/com/android/server/telecom/TelecomBroadcastIntentProcessor.java
index 0be90e0..523b841 100644
--- a/src/com/android/server/telecom/TelecomBroadcastIntentProcessor.java
+++ b/src/com/android/server/telecom/TelecomBroadcastIntentProcessor.java
@@ -101,6 +101,10 @@
public static final String ACTION_CANCEL_REDIRECTED_CALL =
"com.android.server.telecom.CANCEL_REDIRECTED_CALL";
+ public static final String ACTION_HANGUP_CALL = "com.android.server.telecom.HANGUP_CALL";
+ public static final String ACTION_STOP_STREAMING =
+ "com.android.server.telecom.ACTION_STOP_STREAMING";
+
public static final String EXTRA_USERHANDLE = "userhandle";
public static final String EXTRA_REDIRECTION_OUTGOING_CALL_ID =
"android.telecom.extra.REDIRECTION_OUTGOING_CALL_ID";
@@ -242,6 +246,26 @@
} finally {
Log.endSession();
}
+ } else if (ACTION_HANGUP_CALL.equals(action)) {
+ Log.startSession("TBIP.aHC", "streamingDialog");
+ try {
+ Call call = mCallsManager.getCall(intent.getData().getSchemeSpecificPart());
+ if (call != null) {
+ mCallsManager.disconnectCall(call);
+ }
+ } finally {
+ Log.endSession();
+ }
+ } else if (ACTION_STOP_STREAMING.equals(action)) {
+ Log.startSession("TBIP.aSS", "streamingDialog");
+ try {
+ Call call = mCallsManager.getCall(intent.getData().getSchemeSpecificPart());
+ if (call != null) {
+ mCallsManager.stopCallStreaming(call);
+ }
+ } finally {
+ Log.endSession();
+ }
}
}
diff --git a/src/com/android/server/telecom/TelecomSystem.java b/src/com/android/server/telecom/TelecomSystem.java
index 70f3491..3bfb933 100644
--- a/src/com/android/server/telecom/TelecomSystem.java
+++ b/src/com/android/server/telecom/TelecomSystem.java
@@ -48,6 +48,7 @@
import com.android.server.telecom.components.UserCallIntentProcessor;
import com.android.server.telecom.components.UserCallIntentProcessorFactory;
import com.android.server.telecom.ui.AudioProcessingNotification;
+import com.android.server.telecom.ui.CallStreamingNotification;
import com.android.server.telecom.ui.DisconnectedCallNotifier;
import com.android.server.telecom.ui.IncomingCallNotifier;
import com.android.server.telecom.ui.MissedCallNotifierImpl.MissedCallNotifierImplFactory;
@@ -354,6 +355,12 @@
mLock, timeoutsAdapter, clockProxy, emergencyCallDiagnosticLogger);
TransactionManager transactionManager = TransactionManager.getInstance();
+
+ CallStreamingNotification callStreamingNotification =
+ new CallStreamingNotification(mContext,
+ packageName -> AppLabelProxy.Util.getAppLabel(
+ mContext.getPackageManager(), packageName), asyncTaskExecutor);
+
mCallsManager = new CallsManager(
mContext,
mLock,
@@ -391,7 +398,9 @@
blockedNumbersAdapter,
transactionManager,
emergencyCallDiagnosticLogger,
- communicationDeviceTracker);
+ communicationDeviceTracker,
+ callStreamingNotification);
+
mIncomingCallNotifier = incomingCallNotifier;
incomingCallNotifier.setCallsManagerProxy(new IncomingCallNotifier.CallsManagerProxy() {
@Override
diff --git a/src/com/android/server/telecom/TransactionalServiceWrapper.java b/src/com/android/server/telecom/TransactionalServiceWrapper.java
index d83e551..ec95f39 100644
--- a/src/com/android/server/telecom/TransactionalServiceWrapper.java
+++ b/src/com/android/server/telecom/TransactionalServiceWrapper.java
@@ -682,6 +682,7 @@
public void stopCallStreaming(Call call) {
+ Log.i(this, "stopCallStreaming; callid=%s", call.getId());
if (call != null && call.isStreaming()) {
VoipCallTransaction stopStreamingTransaction = createStopStreamingTransaction(call);
addTransactionsToManager(stopStreamingTransaction, new ResultReceiver(null));
diff --git a/src/com/android/server/telecom/ui/CallStreamingNotification.java b/src/com/android/server/telecom/ui/CallStreamingNotification.java
new file mode 100644
index 0000000..752d8c8
--- /dev/null
+++ b/src/com/android/server/telecom/ui/CallStreamingNotification.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2023 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.annotation.Nullable;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Person;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.Icon;
+import android.net.Uri;
+import android.os.UserHandle;
+import android.telecom.Log;
+import android.telecom.PhoneAccount;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.TextUtils;
+import android.text.style.ForegroundColorSpan;
+import androidx.core.graphics.drawable.RoundedBitmapDrawable;
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.server.telecom.AppLabelProxy;
+import com.android.server.telecom.Call;
+import com.android.server.telecom.CallsManagerListenerBase;
+import com.android.server.telecom.R;
+import com.android.server.telecom.TelecomBroadcastIntentProcessor;
+import com.android.server.telecom.components.TelecomBroadcastReceiver;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Class responsible for tracking if there is a call which is being streamed and posting a
+ * notification which informs the user that a call is streaming. The user has two possible actions:
+ * disconnect the call, bring the call back to the current device (stop streaming).
+ */
+public class CallStreamingNotification extends CallsManagerListenerBase implements Call.Listener {
+ // URI scheme used for data related to the notification actions.
+ public static final String CALL_ID_SCHEME = "callid";
+ // The default streaming notification ID.
+ private static final int STREAMING_NOTIFICATION_ID = 90210;
+ // Tag for streaming notification.
+ private static final String NOTIFICATION_TAG =
+ CallStreamingNotification.class.getSimpleName();
+
+ private final Context mContext;
+ private final NotificationManager mNotificationManager;
+ // Used to get the app name for the notification.
+ private final AppLabelProxy mAppLabelProxy;
+ // An executor that can be used to fire off async tasks that do not block Telecom in any manner.
+ private final Executor mAsyncTaskExecutor;
+ // The call which is treaming.
+ private Call mStreamingCall;
+ // Lock for notification post/remove -- these happen outside the Telecom sync lock.
+ private final Object mNotificationLock = new Object();
+
+ // Whether the notification is showing.
+ @GuardedBy("mNotificationLock")
+ private boolean mIsNotificationShowing = false;
+ @GuardedBy("mNotificationLock")
+ private UserHandle mNotificationUserHandle;
+
+ public CallStreamingNotification(@NonNull Context context,
+ @NonNull AppLabelProxy appLabelProxy,
+ @NonNull Executor asyncTaskExecutor) {
+ mContext = context;
+ mNotificationManager = context.getSystemService(NotificationManager.class);
+ mAppLabelProxy = appLabelProxy;
+ mAsyncTaskExecutor = asyncTaskExecutor;
+ }
+
+ @Override
+ public void onCallAdded(Call call) {
+ if (call.isStreaming()) {
+ trackStreamingCall(call);
+ enqueueStreamingNotification(call);
+ }
+ }
+
+ @Override
+ public void onCallRemoved(Call call) {
+ if (call == mStreamingCall) {
+ trackStreamingCall(null);
+ dequeueStreamingNotification();
+ }
+ }
+
+ /**
+ * Handles streaming state changes for a call.
+ * @param call the call
+ * @param isStreaming whether it is streaming or not
+ */
+ @Override
+ public void onCallStreamingStateChanged(Call call, boolean isStreaming) {
+ Log.i(this, "onCallStreamingStateChanged: call=%s, isStreaming=%b", call.getId(),
+ isStreaming);
+
+ if (isStreaming) {
+ trackStreamingCall(call);
+ enqueueStreamingNotification(call);
+ } else {
+ trackStreamingCall(null);
+ dequeueStreamingNotification();
+ }
+ }
+
+ /**
+ * Handles changes to the caller info for a call. Used to ensure we can update the photo uri
+ * if one was found.
+ * @param call the call which the caller info changed on.
+ */
+ @Override
+ public void onCallerInfoChanged(Call call) {
+ if (call == mStreamingCall) {
+ Log.i(this, "onCallerInfoChanged: call=%s, photoUri=%b", call.getId(),
+ call.getContactPhotoUri());
+ enqueueStreamingNotification(call);
+ }
+ }
+
+ /**
+ * Change the streaming call we are tracking.
+ * @param call the call.
+ */
+ private void trackStreamingCall(Call call) {
+ if (mStreamingCall != null) {
+ mStreamingCall.removeListener(this);
+ }
+ mStreamingCall = call;
+ if (mStreamingCall != null) {
+ mStreamingCall.addListener(this);
+ }
+ }
+
+ /**
+ * Enqueue an async task to post/repost the streaming notification.
+ * Note: This happens INSIDE the telecom lock.
+ * @param call the call to post notification for.
+ */
+ private void enqueueStreamingNotification(Call call) {
+ final Bitmap contactPhotoBitmap = call.getPhotoIcon();
+ mAsyncTaskExecutor.execute(() -> {
+ Icon contactPhotoIcon = null;
+ try {
+ if (contactPhotoBitmap != null) {
+ // Make the icon rounded... because there has to be hoops to jump through.
+ RoundedBitmapDrawable roundedDrawable = RoundedBitmapDrawableFactory.create(
+ mContext.getResources(), contactPhotoBitmap);
+ roundedDrawable.setCornerRadius(Math.max(contactPhotoBitmap.getWidth(),
+ contactPhotoBitmap.getHeight()) / 2.0f);
+ contactPhotoIcon = Icon.createWithBitmap(drawableToBitmap(roundedDrawable,
+ contactPhotoBitmap.getWidth(), contactPhotoBitmap.getHeight()));
+ }
+ } catch (Exception e) {
+ // All loads of things can do wrong when working with bitmaps and images, so to
+ // ensure Telecom doesn't crash, lets try/catch to be sure.
+ Log.e(this, e, "enqueueStreamingNotification: Couldn't build rounded icon");
+ }
+ showStreamingNotification(call.getId(),
+ call.getUserHandleFromTargetPhoneAccount(), call.getCallerDisplayName(),
+ call.getHandle(), contactPhotoIcon,
+ call.getTargetPhoneAccount().getComponentName().getPackageName(),
+ call.getConnectTimeMillis());
+ });
+ }
+
+ /**
+ * Dequeues the call streaming notification.
+ * Note: This is yo be called within the Telecom sync lock to launch the task to remove the call
+ * streaming notification.
+ */
+ private void dequeueStreamingNotification() {
+ mAsyncTaskExecutor.execute(() -> hideStreamingNotification());
+ }
+
+ /**
+ * Show the call streaming notification. This is intended to run outside the Telecom sync lock.
+ *
+ * @param callId the call ID we're streaming.
+ * @param userHandle the userhandle for the call.
+ * @param callerName the name of the caller/callee associated with the call
+ * @param callerAddress the address associated with the caller/callee
+ * @param photoIcon the contact photo icon if available
+ * @param appPackageName the package name for the app to post the notification for
+ * @param connectTimeMillis when the call connected (for chronometer in the notification)
+ */
+ private void showStreamingNotification(final String callId, final UserHandle userHandle,
+ String callerName, Uri callerAddress, Icon photoIcon, String appPackageName,
+ long connectTimeMillis) {
+ Log.i(this, "showStreamingNotification; callid=%s, hasPhoto=%b", callId, photoIcon != null);
+
+ // Use the caller name for the label if available, default to app name if none.
+ if (TextUtils.isEmpty(callerName)) {
+ // App did not provide a caller name, so default to app's name.
+ callerName = mAppLabelProxy.getAppLabel(appPackageName).toString();
+ }
+
+ // Action to hangup; this can use the default hangup action from the call style
+ // notification.
+ Intent hangupIntent = new Intent(TelecomBroadcastIntentProcessor.ACTION_HANGUP_CALL,
+ Uri.fromParts(CALL_ID_SCHEME, callId, null),
+ mContext, TelecomBroadcastReceiver.class);
+ PendingIntent hangupPendingIntent = PendingIntent.getBroadcast(mContext, 0, hangupIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ // Action to switch here.
+ Intent switchHereIntent = new Intent(TelecomBroadcastIntentProcessor.ACTION_STOP_STREAMING,
+ Uri.fromParts(CALL_ID_SCHEME, callId, null),
+ mContext, TelecomBroadcastReceiver.class);
+ PendingIntent switchHerePendingIntent = PendingIntent.getBroadcast(mContext, 0,
+ switchHereIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ // Apply a span to the string to colorize it using the "answer" color.
+ Spannable spannable = new SpannableString(
+ mContext.getString(R.string.call_streaming_notification_action_switch_here));
+ spannable.setSpan(new ForegroundColorSpan(
+ com.android.internal.R.color.call_notification_answer_color), 0, spannable.length(),
+ Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+
+ // Use the "phone link" icon per mock.
+ Icon switchHereIcon = Icon.createWithResource(mContext, R.drawable.gm_phonelink);
+ Notification.Action.Builder switchHereBuilder = new Notification.Action.Builder(
+ switchHereIcon,
+ spannable,
+ switchHerePendingIntent);
+ Notification.Action switchHereAction = switchHereBuilder.build();
+
+ // Notifications use a "person" entity to identify caller/callee.
+ Person.Builder personBuilder = new Person.Builder()
+ .setName(callerName);
+
+ // Some apps use phone numbers to identify; these are something the notification framework
+ // can lookup in contacts to provide more data
+ if (callerAddress != null && PhoneAccount.SCHEME_TEL.equals(callerAddress)) {
+ personBuilder.setUri(callerAddress.toString());
+ }
+ if (photoIcon != null) {
+ personBuilder.setIcon(photoIcon);
+ }
+ Person person = personBuilder.build();
+
+ // Call Style notification requires a full screen intent, so we'll just link in a null
+ // pending intent
+ Intent nullIntent = new Intent();
+ PendingIntent nullPendingIntent = PendingIntent.getBroadcast(mContext, 0, nullIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ Notification.Builder builder = new Notification.Builder(mContext,
+ NotificationChannelManager.CHANNEL_ID_CALL_STREAMING)
+ // Use call style to get the general look and feel for the notification; it provides
+ // a hangup action with the right action already so we can leverage that. The
+ // "switch here" action will be a custom action defined later.
+ .setStyle(Notification.CallStyle.forOngoingCall(person, hangupPendingIntent))
+ .setSmallIcon(R.drawable.ic_phone)
+ .setContentText(mContext.getString(
+ R.string.call_streaming_notification_body))
+ // Report call time
+ .setWhen(connectTimeMillis)
+ .setShowWhen(true)
+ .setUsesChronometer(true)
+ // Set the full screen intent; this is just tricking notification manager into
+ // letting us use this style. Sssh.
+ .setFullScreenIntent(nullPendingIntent, true)
+ .setColorized(true)
+ .addAction(switchHereAction);
+ Notification notification = builder.build();
+
+ synchronized(mNotificationLock) {
+ mIsNotificationShowing = true;
+ mNotificationUserHandle = userHandle;
+ try {
+ mNotificationManager.notifyAsUser(NOTIFICATION_TAG, STREAMING_NOTIFICATION_ID,
+ notification, userHandle);
+ } catch (Exception e) {
+ // We don't want to crash Telecom if something changes with the requirements for the
+ // notification.
+ Log.e(this, e, "Notification post failed.");
+ }
+ }
+ }
+
+ /**
+ * Removes the posted streaming notification. Intended to run outside the telecom lock.
+ */
+ private void hideStreamingNotification() {
+ Log.i(this, "hideStreamingNotification");
+ synchronized(mNotificationLock) {
+ if (mIsNotificationShowing) {
+ mIsNotificationShowing = false;
+ mNotificationManager.cancelAsUser(NOTIFICATION_TAG,
+ STREAMING_NOTIFICATION_ID, mNotificationUserHandle);
+ }
+ }
+ }
+
+ public static Bitmap drawableToBitmap(@Nullable Drawable drawable, int width, int height) {
+ if (drawable == null) {
+ return null;
+ }
+
+ Bitmap bitmap;
+ if (drawable instanceof BitmapDrawable) {
+ bitmap = ((BitmapDrawable) drawable).getBitmap();
+ } else {
+ if (width > 0 || height > 0) {
+ bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ } else if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) {
+ // Needed for drawables that are just a colour.
+ bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+ } else {
+ bitmap =
+ Bitmap.createBitmap(
+ drawable.getIntrinsicWidth(),
+ drawable.getIntrinsicHeight(),
+ Bitmap.Config.ARGB_8888);
+ }
+
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+ }
+ return bitmap;
+ }
+}
diff --git a/src/com/android/server/telecom/ui/NotificationChannelManager.java b/src/com/android/server/telecom/ui/NotificationChannelManager.java
index 58794a6..a0baa03 100644
--- a/src/com/android/server/telecom/ui/NotificationChannelManager.java
+++ b/src/com/android/server/telecom/ui/NotificationChannelManager.java
@@ -40,6 +40,7 @@
public static final String CHANNEL_ID_AUDIO_PROCESSING = "TelecomBackgroundAudioProcessing";
public static final String CHANNEL_ID_DISCONNECTED_CALLS = "TelecomDisconnectedCalls";
public static final String CHANNEL_ID_IN_CALL_SERVICE_CRASH = "TelecomInCallServiceCrash";
+ public static final String CHANNEL_ID_CALL_STREAMING = "TelecomCallStreaming";
private BroadcastReceiver mLocaleChangeReceiver = new BroadcastReceiver() {
@Override
@@ -63,6 +64,7 @@
createOrUpdateChannel(context, CHANNEL_ID_AUDIO_PROCESSING);
createOrUpdateChannel(context, CHANNEL_ID_DISCONNECTED_CALLS);
createOrUpdateChannel(context, CHANNEL_ID_IN_CALL_SERVICE_CRASH);
+ createOrUpdateChannel(context, CHANNEL_ID_CALL_STREAMING);
}
private void createOrUpdateChannel(Context context, String channelId) {
@@ -127,6 +129,14 @@
lights = true;
vibration = true;
sound = null;
+ case CHANNEL_ID_CALL_STREAMING:
+ name = context.getText(R.string.notification_channel_call_streaming);
+ importance = NotificationManager.IMPORTANCE_DEFAULT;
+ canShowBadge = false;
+ lights = false;
+ vibration = false;
+ sound = null;
+ break;
}
NotificationChannel channel = new NotificationChannel(channelId, name, importance);
diff --git a/src/com/android/server/telecom/voip/IncomingCallTransaction.java b/src/com/android/server/telecom/voip/IncomingCallTransaction.java
index c0bb93d..d35030c 100644
--- a/src/com/android/server/telecom/voip/IncomingCallTransaction.java
+++ b/src/com/android/server/telecom/voip/IncomingCallTransaction.java
@@ -17,6 +17,7 @@
package com.android.server.telecom.voip;
import static android.telecom.CallAttributes.CALL_CAPABILITIES_KEY;
+import static android.telecom.CallAttributes.DISPLAY_NAME_KEY;
import android.os.Bundle;
import android.telecom.CallAttributes;
@@ -80,6 +81,9 @@
mExtras.putString(TelecomManager.TRANSACTION_CALL_ID_KEY, mCallId);
mExtras.putInt(CALL_CAPABILITIES_KEY, callAttributes.getCallCapabilities());
mExtras.putInt(TelecomManager.EXTRA_INCOMING_VIDEO_STATE, callAttributes.getCallType());
+ mExtras.putCharSequence(DISPLAY_NAME_KEY, callAttributes.getDisplayName());
+ mExtras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
+ callAttributes.getAddress());
return mExtras;
}
}
diff --git a/src/com/android/server/telecom/voip/OutgoingCallTransaction.java b/src/com/android/server/telecom/voip/OutgoingCallTransaction.java
index 0b17da2..b2625e6 100644
--- a/src/com/android/server/telecom/voip/OutgoingCallTransaction.java
+++ b/src/com/android/server/telecom/voip/OutgoingCallTransaction.java
@@ -18,6 +18,7 @@
import static android.Manifest.permission.CALL_PRIVILEGED;
import static android.telecom.CallAttributes.CALL_CAPABILITIES_KEY;
+import static android.telecom.CallAttributes.DISPLAY_NAME_KEY;
import static android.telecom.CallException.CODE_CALL_NOT_PERMITTED_AT_PRESENT_TIME;
import android.content.Context;
@@ -126,6 +127,7 @@
mExtras.putInt(CALL_CAPABILITIES_KEY, callAttributes.getCallCapabilities());
mExtras.putInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
callAttributes.getCallType());
+ mExtras.putCharSequence(DISPLAY_NAME_KEY, callAttributes.getDisplayName());
return mExtras;
}
}
diff --git a/src/com/android/server/telecom/voip/VoipCallTransaction.java b/src/com/android/server/telecom/voip/VoipCallTransaction.java
index a1cc13c..38f1d54 100644
--- a/src/com/android/server/telecom/voip/VoipCallTransaction.java
+++ b/src/com/android/server/telecom/voip/VoipCallTransaction.java
@@ -18,6 +18,7 @@
import android.os.Handler;
import android.os.HandlerThread;
+import android.telecom.Log;
import com.android.server.telecom.LoggedHandlerExecutor;
import com.android.server.telecom.TelecomSystem;
@@ -80,7 +81,11 @@
}
finish();
return null;
- });
+ })
+ .exceptionally((throwable) -> {
+ Log.e(this, throwable, "Error while executing transaction.");
+ return null;
+ });;
}
public CompletionStage<VoipCallTransactionResult> processTransaction(Void v) {
diff --git a/testapps/streamingtest/Android.bp b/testapps/streamingtest/Android.bp
new file mode 100644
index 0000000..bd0a582
--- /dev/null
+++ b/testapps/streamingtest/Android.bp
@@ -0,0 +1,31 @@
+//
+// Copyright (C) 2022 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "streamingTestApp",
+ static_libs: [
+ "androidx.legacy_legacy-support-v4",
+ "guava",
+ ],
+ srcs: ["src/**/*.java"],
+ platform_apis: true,
+ certificate: "platform",
+ privileged: true,
+}
diff --git a/testapps/streamingtest/AndroidManifest.xml b/testapps/streamingtest/AndroidManifest.xml
new file mode 100644
index 0000000..47e4abc
--- /dev/null
+++ b/testapps/streamingtest/AndroidManifest.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ coreApp="true"
+ package="com.android.server.telecom.streamingtest">
+
+ <uses-sdk android:minSdkVersion="28"
+ android:targetSdkVersion="33"/>
+
+ <uses-permission android:name="android.permission.RECORD_AUDIO"/>
+ <uses-permission android:name="android.permission.CALL_AUDIO_INTERCEPTION"/>
+
+ <application android:label="Streaming Test App">
+ <uses-library android:name="android.test.runner"/>
+
+ <service android:name="com.android.server.telecom.streamingtest.StreamingService"
+ android:exported="true"
+ android:permission="android.permission.BIND_CALL_STREAMING_SERVICE">
+ <intent-filter>
+ <action android:name="android.telecom.CallStreamingService"/>
+ </intent-filter>
+ </service>
+ </application>
+</manifest>
diff --git a/testapps/streamingtest/src/com/android/server/telecom/streamingtest/StreamingService.java b/testapps/streamingtest/src/com/android/server/telecom/streamingtest/StreamingService.java
new file mode 100644
index 0000000..c76b349
--- /dev/null
+++ b/testapps/streamingtest/src/com/android/server/telecom/streamingtest/StreamingService.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2023 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.streamingtest;
+
+import android.annotation.NonNull;
+import android.content.Intent;
+import android.telecom.CallStreamingService;
+import android.telecom.StreamingCall;
+import android.telecom.Log;
+
+public class StreamingService extends CallStreamingService {
+ @Override
+ public void onCallStreamingStarted(@NonNull StreamingCall call) {
+ Log.i(this, "onCallStreamingStarted: call %s", call);
+ }
+
+ @Override
+ public void onCallStreamingStopped() {
+ Log.i(this, "onCallStreamingStopped");
+ }
+
+ @Override
+ public void onCallStreamingStateChanged(int state) {
+ Log.i(this, "onCallStreamingStateChanged; state=%d", state);
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ Log.i(this, "onUnbind");
+ return false;
+ }
+}
diff --git a/testapps/transactionalVoipApp/src/com/android/server/telecom/transactionalVoipApp/InCallActivity.java b/testapps/transactionalVoipApp/src/com/android/server/telecom/transactionalVoipApp/InCallActivity.java
index b868b70..3e53800 100644
--- a/testapps/transactionalVoipApp/src/com/android/server/telecom/transactionalVoipApp/InCallActivity.java
+++ b/testapps/transactionalVoipApp/src/com/android/server/telecom/transactionalVoipApp/InCallActivity.java
@@ -57,6 +57,11 @@
setContentView(R.layout.in_call_activity);
Bundle extras = getIntent().getExtras();
+ // Copy the extras with properties like call direction into the extras so the below
+ // code can access them.
+ if (extras != null && extras.containsKey(Utils.sEXTRAS_KEY)) {
+ extras.putAll(extras.getBundle(Utils.sEXTRAS_KEY));
+ }
if (extras != null) {
mCallDirection = extras.getInt(Utils.sCALL_DIRECTION_KEY, DIRECTION_INCOMING);
}
@@ -211,7 +216,7 @@
Utils.PHONE_ACCOUNT_HANDLE,
mCallDirection,
"Alan Turing",
- Uri.parse("tel:6506959001")).build();
+ Uri.parse("tel:+16506959001")).build();
mTelecomManager.addCall(callAttributes, Runnable::run,
new OutcomeReceiver<CallControl, CallException>() {
diff --git a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
index afe7e18..58ea2a1 100644
--- a/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
+++ b/tests/src/com/android/server/telecom/tests/CallsManagerTest.java
@@ -126,6 +126,7 @@
import com.android.server.telecom.callfiltering.BlockedNumbersAdapter;
import com.android.server.telecom.callfiltering.CallFilteringResult;
import com.android.server.telecom.ui.AudioProcessingNotification;
+import com.android.server.telecom.ui.CallStreamingNotification;
import com.android.server.telecom.ui.DisconnectedCallNotifier;
import com.android.server.telecom.ui.ToastFactory;
import com.android.server.telecom.voip.TransactionManager;
@@ -259,6 +260,7 @@
@Mock private BlockedNumbersAdapter mBlockedNumbersAdapter;
@Mock private PhoneCapability mPhoneCapability;
@Mock private CallAudioCommunicationDeviceTracker mCommunicationDeviceTracker;
+ @Mock private CallStreamingNotification mCallStreamingNotification;
private CallsManager mCallsManager;
@@ -330,7 +332,8 @@
mBlockedNumbersAdapter,
TransactionManager.getTestInstance(),
mEmergencyCallDiagnosticLogger,
- mCommunicationDeviceTracker);
+ mCommunicationDeviceTracker,
+ mCallStreamingNotification);
when(mPhoneAccountRegistrar.getPhoneAccount(
eq(SELF_MANAGED_HANDLE), any())).thenReturn(SELF_MANAGED_ACCOUNT);