diff --git a/core/java/com/android/internal/util/ImageUtils.java b/core/java/com/android/internal/util/ImageUtils.java index 397b2c052514a..62dea9dc82e01 100644 --- a/core/java/com/android/internal/util/ImageUtils.java +++ b/core/java/com/android/internal/util/ImageUtils.java @@ -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); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java index 145e52729e110..dfd4362e23731 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java @@ -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 diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipMediaController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipMediaController.java index 8a50f22335738..65a12d629c5a8 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipMediaController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipMediaController.java @@ -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 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 mActionListeners = new ArrayList<>(); private final ArrayList mMetadataListeners = new ArrayList<>(); + private final ArrayList 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)); + } + } } diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java index fcd1f9583297b..766779413094d 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java @@ -293,6 +293,8 @@ public class TvPipController implements PipTransitionController.PipTransitionCal } mTvPipBoundsState.setTvPipManuallyCollapsed(!expanding); mTvPipBoundsState.setTvPipExpanded(expanding); + mPipNotificationController.updateExpansionState(); + updatePinnedStackBounds(); } diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipNotificationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipNotificationController.java index 4033f030b702a..61a609d9755e5 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipNotificationController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipNotificationController.java @@ -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. - *

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. + *

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 mCustomActions = new ArrayList<>(); + private final List 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 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 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 actions = new ArrayList<>(); + + // 1. Fullscreen + actions.add(getFullscreenAction()); + // 2. Close + actions.add(getCloseAction()); + // 3. App actions + final List 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(); } }