Merge "Add more information to the TV PiP notification" into tm-dev

This commit is contained in:
Jacqueline Bronger
2022-05-06 10:39:27 +00:00
committed by Android (Google) Code Review
5 changed files with 318 additions and 74 deletions

View File

@@ -137,6 +137,18 @@ public class ImageUtils {
*/
public static Bitmap buildScaledBitmap(Drawable drawable, int maxWidth,
int maxHeight) {
return buildScaledBitmap(drawable, maxWidth, maxHeight, false);
}
/**
* Convert a drawable to a bitmap, scaled to fit within maxWidth and maxHeight.
*
* @param allowUpscaling if true, the drawable will not only be scaled down, but also scaled up
* to fit within the maximum size given. This is useful for converting
* vectorized icons which usually have a very small intrinsic size.
*/
public static Bitmap buildScaledBitmap(Drawable drawable, int maxWidth,
int maxHeight, boolean allowUpscaling) {
if (drawable == null) {
return null;
}
@@ -155,7 +167,9 @@ public class ImageUtils {
// a large notification icon if necessary
float ratio = Math.min((float) maxWidth / (float) originalWidth,
(float) maxHeight / (float) originalHeight);
ratio = Math.min(1.0f, ratio);
if (!allowUpscaling) {
ratio = Math.min(1.0f, ratio);
}
int scaledWidth = (int) (ratio * originalWidth);
int scaledHeight = (int) (ratio * originalHeight);
Bitmap result = Bitmap.createBitmap(scaledWidth, scaledHeight, Config.ARGB_8888);

View File

@@ -143,8 +143,11 @@ public abstract class TvPipModule {
@Provides
static TvPipNotificationController provideTvPipNotificationController(Context context,
PipMediaController pipMediaController,
PipParamsChangedForwarder pipParamsChangedForwarder,
TvPipBoundsState tvPipBoundsState,
@ShellMainThread Handler mainHandler) {
return new TvPipNotificationController(context, pipMediaController, mainHandler);
return new TvPipNotificationController(context, pipMediaController,
pipParamsChangedForwarder, tvPipBoundsState, mainHandler);
}
@WMSingleton

View File

@@ -32,6 +32,7 @@ import android.content.IntentFilter;
import android.graphics.drawable.Icon;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.os.Handler;
@@ -64,7 +65,7 @@ public class PipMediaController {
*/
public interface ActionListener {
/**
* Called when the media actions changes.
* Called when the media actions changed.
*/
void onMediaActionsChanged(List<RemoteAction> actions);
}
@@ -74,11 +75,21 @@ public class PipMediaController {
*/
public interface MetadataListener {
/**
* Called when the media metadata changes.
* Called when the media metadata changed.
*/
void onMediaMetadataChanged(MediaMetadata metadata);
}
/**
* A listener interface to receive notification on changes to the media session token.
*/
public interface TokenListener {
/**
* Called when the media session token changed.
*/
void onMediaSessionTokenChanged(MediaSession.Token token);
}
private final Context mContext;
private final Handler mMainHandler;
private final HandlerExecutor mHandlerExecutor;
@@ -133,6 +144,7 @@ public class PipMediaController {
private final ArrayList<ActionListener> mActionListeners = new ArrayList<>();
private final ArrayList<MetadataListener> mMetadataListeners = new ArrayList<>();
private final ArrayList<TokenListener> mTokenListeners = new ArrayList<>();
public PipMediaController(Context context, Handler mainHandler) {
mContext = context;
@@ -204,6 +216,31 @@ public class PipMediaController {
mMetadataListeners.remove(listener);
}
/**
* Adds a new token listener.
*/
public void addTokenListener(TokenListener listener) {
if (!mTokenListeners.contains(listener)) {
mTokenListeners.add(listener);
listener.onMediaSessionTokenChanged(getToken());
}
}
/**
* Removes a token listener.
*/
public void removeTokenListener(TokenListener listener) {
listener.onMediaSessionTokenChanged(null);
mTokenListeners.remove(listener);
}
private MediaSession.Token getToken() {
if (mMediaController == null) {
return null;
}
return mMediaController.getSessionToken();
}
private MediaMetadata getMediaMetadata() {
return mMediaController != null ? mMediaController.getMetadata() : null;
}
@@ -294,6 +331,7 @@ public class PipMediaController {
}
notifyActionsChanged();
notifyMetadataChanged(getMediaMetadata());
notifyTokenChanged(getToken());
// TODO(winsonc): Consider if we want to close the PIP after a timeout (like on TV)
}
@@ -317,4 +355,10 @@ public class PipMediaController {
mMetadataListeners.forEach(l -> l.onMediaMetadataChanged(metadata));
}
}
private void notifyTokenChanged(MediaSession.Token token) {
if (!mTokenListeners.isEmpty()) {
mTokenListeners.forEach(l -> l.onMediaSessionTokenChanged(token));
}
}
}

View File

@@ -293,6 +293,8 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
}
mTvPipBoundsState.setTvPipManuallyCollapsed(!expanding);
mTvPipBoundsState.setTvPipExpanded(expanding);
mPipNotificationController.updateExpansionState();
updatePinnedStackBounds();
}

View File

@@ -16,36 +16,47 @@
package com.android.wm.shell.pip.tv;
import static android.app.Notification.Action.SEMANTIC_ACTION_DELETE;
import static android.app.Notification.Action.SEMANTIC_ACTION_NONE;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.RemoteAction;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.media.MediaMetadata;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.media.session.MediaSession;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
import com.android.internal.protolog.common.ProtoLog;
import com.android.internal.util.ImageUtils;
import com.android.wm.shell.R;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipParamsChangedForwarder;
import com.android.wm.shell.pip.PipUtils;
import com.android.wm.shell.protolog.ShellProtoLogGroup;
import java.util.Objects;
import java.util.ArrayList;
import java.util.List;
/**
* A notification that informs users that PIP is running and also provides PIP controls.
* <p>Once it's created, it will manage the PIP notification UI by itself except for handling
* configuration changes.
* A notification that informs users that PiP is running and also provides PiP controls.
* <p>Once it's created, it will manage the PiP notification UI by itself except for handling
* configuration changes and user initiated expanded PiP toggling.
*/
public class TvPipNotificationController {
private static final String TAG = "TvPipNotification";
private static final boolean DEBUG = TvPipController.DEBUG;
// Referenced in com.android.systemui.util.NotificationChannels.
public static final String NOTIFICATION_CHANNEL = "TVPIP";
@@ -60,6 +71,8 @@ public class TvPipNotificationController {
"com.android.wm.shell.pip.tv.notification.action.MOVE_PIP";
private static final String ACTION_TOGGLE_EXPANDED_PIP =
"com.android.wm.shell.pip.tv.notification.action.TOGGLE_EXPANDED_PIP";
private static final String ACTION_FULLSCREEN =
"com.android.wm.shell.pip.tv.notification.action.FULLSCREEN";
private final Context mContext;
private final PackageManager mPackageManager;
@@ -68,44 +81,88 @@ public class TvPipNotificationController {
private final ActionBroadcastReceiver mActionBroadcastReceiver;
private final Handler mMainHandler;
private Delegate mDelegate;
private final TvPipBoundsState mTvPipBoundsState;
private String mDefaultTitle;
private final List<RemoteAction> mCustomActions = new ArrayList<>();
private final List<RemoteAction> mMediaActions = new ArrayList<>();
private RemoteAction mCustomCloseAction;
private MediaSession.Token mMediaSessionToken;
/** Package name for the application that owns PiP window. */
private String mPackageName;
private boolean mNotified;
private String mMediaTitle;
private Bitmap mArt;
private boolean mIsNotificationShown;
private String mPipTitle;
private String mPipSubtitle;
private Bitmap mActivityIcon;
public TvPipNotificationController(Context context, PipMediaController pipMediaController,
PipParamsChangedForwarder pipParamsChangedForwarder, TvPipBoundsState tvPipBoundsState,
Handler mainHandler) {
mContext = context;
mPackageManager = context.getPackageManager();
mNotificationManager = context.getSystemService(NotificationManager.class);
mMainHandler = mainHandler;
mTvPipBoundsState = tvPipBoundsState;
mNotificationBuilder = new Notification.Builder(context, NOTIFICATION_CHANNEL)
.setLocalOnly(true)
.setOngoing(false)
.setOngoing(true)
.setCategory(Notification.CATEGORY_SYSTEM)
.setShowWhen(true)
.setSmallIcon(R.drawable.pip_icon)
.setAllowSystemGeneratedContextualActions(false)
.setContentIntent(createPendingIntent(context, ACTION_FULLSCREEN))
.setDeleteIntent(getCloseAction().actionIntent)
.extend(new Notification.TvExtender()
.setContentIntent(createPendingIntent(context, ACTION_SHOW_PIP_MENU))
.setDeleteIntent(createPendingIntent(context, ACTION_CLOSE_PIP)));
mActionBroadcastReceiver = new ActionBroadcastReceiver();
pipMediaController.addMetadataListener(this::onMediaMetadataChanged);
pipMediaController.addActionListener(this::onMediaActionsChanged);
pipMediaController.addTokenListener(this::onMediaSessionTokenChanged);
pipParamsChangedForwarder.addListener(
new PipParamsChangedForwarder.PipParamsChangedCallback() {
@Override
public void onExpandedAspectRatioChanged(float ratio) {
updateExpansionState();
}
@Override
public void onActionsChanged(List<RemoteAction> actions,
RemoteAction closeAction) {
mCustomActions.clear();
mCustomActions.addAll(actions);
mCustomCloseAction = closeAction;
updateNotificationContent();
}
@Override
public void onTitleChanged(String title) {
mPipTitle = title;
updateNotificationContent();
}
@Override
public void onSubtitleChanged(String subtitle) {
mPipSubtitle = subtitle;
updateNotificationContent();
}
});
onConfigurationChanged(context);
}
void setDelegate(Delegate delegate) {
if (DEBUG) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: setDelegate(), delegate=%s", TAG, delegate);
}
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: setDelegate(), delegate=%s",
TAG, delegate);
if (mDelegate != null) {
throw new IllegalStateException(
"The delegate has already been set and should not change.");
@@ -118,90 +175,181 @@ public class TvPipNotificationController {
}
void show(String packageName) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: show %s", TAG, packageName);
if (mDelegate == null) {
throw new IllegalStateException("Delegate is not set.");
}
mIsNotificationShown = true;
mPackageName = packageName;
update();
mActivityIcon = getActivityIcon();
mActionBroadcastReceiver.register();
updateNotificationContent();
}
void dismiss() {
mNotificationManager.cancel(NOTIFICATION_TAG, SystemMessage.NOTE_TV_PIP);
mNotified = false;
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: dismiss()", TAG);
mIsNotificationShown = false;
mPackageName = null;
mActionBroadcastReceiver.unregister();
mNotificationManager.cancel(NOTIFICATION_TAG, SystemMessage.NOTE_TV_PIP);
}
private void onMediaMetadataChanged(MediaMetadata metadata) {
if (updateMediaControllerMetadata(metadata) && mNotified) {
// update notification
update();
private Notification.Action getToggleAction(boolean expanded) {
if (expanded) {
return createSystemAction(R.drawable.pip_ic_collapse,
R.string.pip_collapse, ACTION_TOGGLE_EXPANDED_PIP);
} else {
return createSystemAction(R.drawable.pip_ic_expand, R.string.pip_expand,
ACTION_TOGGLE_EXPANDED_PIP);
}
}
private Notification.Action createSystemAction(int iconRes, int titleRes, String action) {
Notification.Action.Builder builder = new Notification.Action.Builder(
Icon.createWithResource(mContext, iconRes),
mContext.getString(titleRes),
createPendingIntent(mContext, action));
builder.setContextual(true);
return builder.build();
}
private void onMediaActionsChanged(List<RemoteAction> actions) {
mMediaActions.clear();
mMediaActions.addAll(actions);
if (mCustomActions.isEmpty()) {
updateNotificationContent();
}
}
private void onMediaSessionTokenChanged(MediaSession.Token token) {
mMediaSessionToken = token;
updateNotificationContent();
}
private Notification.Action remoteToNotificationAction(RemoteAction action) {
return remoteToNotificationAction(action, SEMANTIC_ACTION_NONE);
}
private Notification.Action remoteToNotificationAction(RemoteAction action,
int semanticAction) {
Notification.Action.Builder builder = new Notification.Action.Builder(action.getIcon(),
action.getTitle(),
action.getActionIntent());
if (action.getContentDescription() != null) {
Bundle extras = new Bundle();
extras.putCharSequence(Notification.EXTRA_PICTURE_CONTENT_DESCRIPTION,
action.getContentDescription());
builder.addExtras(extras);
}
builder.setSemanticAction(semanticAction);
builder.setContextual(true);
return builder.build();
}
private Notification.Action[] getNotificationActions() {
final List<Notification.Action> actions = new ArrayList<>();
// 1. Fullscreen
actions.add(getFullscreenAction());
// 2. Close
actions.add(getCloseAction());
// 3. App actions
final List<RemoteAction> appActions =
mCustomActions.isEmpty() ? mMediaActions : mCustomActions;
for (RemoteAction appAction : appActions) {
if (PipUtils.remoteActionsMatch(mCustomCloseAction, appAction)
|| !appAction.isEnabled()) {
continue;
}
actions.add(remoteToNotificationAction(appAction));
}
// 4. Move
actions.add(getMoveAction());
// 5. Toggle expansion (if expanded PiP enabled)
if (mTvPipBoundsState.getDesiredTvExpandedAspectRatio() > 0
&& mTvPipBoundsState.isTvExpandedPipSupported()) {
actions.add(getToggleAction(mTvPipBoundsState.isTvPipExpanded()));
}
return actions.toArray(new Notification.Action[0]);
}
private Notification.Action getCloseAction() {
if (mCustomCloseAction == null) {
return createSystemAction(R.drawable.pip_ic_close_white, R.string.pip_close,
ACTION_CLOSE_PIP);
} else {
return remoteToNotificationAction(mCustomCloseAction, SEMANTIC_ACTION_DELETE);
}
}
private Notification.Action getFullscreenAction() {
return createSystemAction(R.drawable.pip_ic_fullscreen_white,
R.string.pip_fullscreen, ACTION_FULLSCREEN);
}
private Notification.Action getMoveAction() {
return createSystemAction(R.drawable.pip_ic_move_white, R.string.pip_move,
ACTION_MOVE_PIP);
}
/**
* Called by {@link PipController} when the configuration is changed.
* Called by {@link TvPipController} when the configuration is changed.
*/
void onConfigurationChanged(Context context) {
mDefaultTitle = context.getResources().getString(R.string.pip_notification_unknown_title);
if (mNotified) {
// Update the notification.
update();
}
updateNotificationContent();
}
private void update() {
mNotified = true;
void updateExpansionState() {
updateNotificationContent();
}
private void updateNotificationContent() {
if (mPackageManager == null || !mIsNotificationShown) {
return;
}
Notification.Action[] actions = getNotificationActions();
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: update(), title: %s, subtitle: %s, mediaSessionToken: %s, #actions: %s", TAG,
getNotificationTitle(), mPipSubtitle, mMediaSessionToken, actions.length);
for (Notification.Action action : actions) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, "%s: action: %s", TAG,
action.toString());
}
mNotificationBuilder
.setWhen(System.currentTimeMillis())
.setContentTitle(getNotificationTitle());
if (mArt != null) {
mNotificationBuilder.setStyle(new Notification.BigPictureStyle()
.bigPicture(mArt));
} else {
mNotificationBuilder.setStyle(null);
}
.setContentTitle(getNotificationTitle())
.setContentText(mPipSubtitle)
.setSubText(getApplicationLabel(mPackageName))
.setActions(actions);
setPipIcon();
Bundle extras = new Bundle();
extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, mMediaSessionToken);
mNotificationBuilder.setExtras(extras);
// TvExtender not recognized if not set last.
mNotificationBuilder.extend(new Notification.TvExtender()
.setContentIntent(createPendingIntent(mContext, ACTION_SHOW_PIP_MENU))
.setDeleteIntent(createPendingIntent(mContext, ACTION_CLOSE_PIP)));
mNotificationManager.notify(NOTIFICATION_TAG, SystemMessage.NOTE_TV_PIP,
mNotificationBuilder.build());
}
private boolean updateMediaControllerMetadata(MediaMetadata metadata) {
String title = null;
Bitmap art = null;
if (metadata != null) {
title = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE);
if (TextUtils.isEmpty(title)) {
title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE);
}
art = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
if (art == null) {
art = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART);
}
}
if (TextUtils.equals(title, mMediaTitle) && Objects.equals(art, mArt)) {
return false;
}
mMediaTitle = title;
mArt = art;
return true;
}
private String getNotificationTitle() {
if (!TextUtils.isEmpty(mMediaTitle)) {
return mMediaTitle;
if (!TextUtils.isEmpty(mPipTitle)) {
return mPipTitle;
}
final String applicationTitle = getApplicationLabel(mPackageName);
if (!TextUtils.isEmpty(applicationTitle)) {
return applicationTitle;
}
return mDefaultTitle;
}
@@ -214,10 +362,37 @@ public class TvPipNotificationController {
}
}
private void setPipIcon() {
if (mActivityIcon != null) {
mNotificationBuilder.setLargeIcon(mActivityIcon);
return;
}
// Fallback: Picture-in-Picture icon
mNotificationBuilder.setLargeIcon(Icon.createWithResource(mContext, R.drawable.pip_icon));
}
private Bitmap getActivityIcon() {
if (mContext == null) return null;
ComponentName componentName = PipUtils.getTopPipActivity(mContext).first;
if (componentName == null) return null;
Drawable drawable;
try {
drawable = mPackageManager.getActivityIcon(componentName);
} catch (PackageManager.NameNotFoundException e) {
return null;
}
int width = mContext.getResources().getDimensionPixelSize(
android.R.dimen.notification_large_icon_width);
int height = mContext.getResources().getDimensionPixelSize(
android.R.dimen.notification_large_icon_height);
return ImageUtils.buildScaledBitmap(drawable, width, height, /* allowUpscaling */ true);
}
private static PendingIntent createPendingIntent(Context context, String action) {
return PendingIntent.getBroadcast(context, 0,
new Intent(action).setPackage(context.getPackageName()),
PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE);
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private class ActionBroadcastReceiver extends BroadcastReceiver {
@@ -228,6 +403,7 @@ public class TvPipNotificationController {
mIntentFilter.addAction(ACTION_SHOW_PIP_MENU);
mIntentFilter.addAction(ACTION_MOVE_PIP);
mIntentFilter.addAction(ACTION_TOGGLE_EXPANDED_PIP);
mIntentFilter.addAction(ACTION_FULLSCREEN);
}
boolean mRegistered = false;
@@ -249,10 +425,8 @@ public class TvPipNotificationController {
@Override
public void onReceive(Context context, Intent intent) {
final String action = intent.getAction();
if (DEBUG) {
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: on(Broadcast)Receive(), action=%s", TAG, action);
}
ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE,
"%s: on(Broadcast)Receive(), action=%s", TAG, action);
if (ACTION_SHOW_PIP_MENU.equals(action)) {
mDelegate.showPictureInPictureMenu();
@@ -262,14 +436,21 @@ public class TvPipNotificationController {
mDelegate.enterPipMovementMenu();
} else if (ACTION_TOGGLE_EXPANDED_PIP.equals(action)) {
mDelegate.togglePipExpansion();
} else if (ACTION_FULLSCREEN.equals(action)) {
mDelegate.movePipToFullscreen();
}
}
}
interface Delegate {
void showPictureInPictureMenu();
void closePip();
void enterPipMovementMenu();
void togglePipExpansion();
void movePipToFullscreen();
}
}