blob: a2e9b64046fd26bede5a6f0123ecf59c399b0b69 [file] [log] [blame]
/*
* Copyright (C) 2020 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.wm.shell.pip.tv;
import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
import android.annotation.IntDef;
import android.app.ActivityManager;
import android.app.ActivityTaskManager;
import android.app.RemoteAction;
import android.app.TaskInfo;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ParceledListSlice;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.RemoteException;
import android.util.Log;
import android.view.DisplayInfo;
import com.android.wm.shell.R;
import com.android.wm.shell.WindowManagerShellWrapper;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.TaskStackListenerCallback;
import com.android.wm.shell.common.TaskStackListenerImpl;
import com.android.wm.shell.pip.PinnedStackListenerForwarder;
import com.android.wm.shell.pip.Pip;
import com.android.wm.shell.pip.PipBoundsAlgorithm;
import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipTaskOrganizer;
import com.android.wm.shell.pip.PipTransitionController;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Manages the picture-in-picture (PIP) UI and states.
*/
public class TvPipController implements PipTransitionController.PipTransitionCallback,
TvPipMenuController.Delegate, TvPipNotificationController.Delegate {
private static final String TAG = "TvPipController";
static final boolean DEBUG = true;
private static final int NONEXISTENT_TASK_ID = -1;
@Retention(RetentionPolicy.SOURCE)
@IntDef(prefix = { "STATE_" }, value = {
STATE_NO_PIP,
STATE_PIP,
STATE_PIP_MENU
})
public @interface State {}
/**
* State when there is no applications in Pip.
*/
private static final int STATE_NO_PIP = 0;
/**
* State when there is an applications in Pip and the Pip window located at its "normal" place
* (usually the bottom right corner).
*/
private static final int STATE_PIP = 1;
/**
* State when there is an applications in Pip and the Pip menu is open. In this state Pip window
* is usually moved from its "normal" position on the screen to the "menu" position - which is
* often at the middle of the screen, and gets slightly scaled up.
*/
private static final int STATE_PIP_MENU = 2;
private final Context mContext;
private final PipBoundsState mPipBoundsState;
private final PipBoundsAlgorithm mPipBoundsAlgorithm;
private final PipTaskOrganizer mPipTaskOrganizer;
private final PipMediaController mPipMediaController;
private final TvPipNotificationController mPipNotificationController;
private final TvPipMenuController mTvPipMenuController;
private final ShellExecutor mMainExecutor;
private final TvPipImpl mImpl = new TvPipImpl();
private @State int mState = STATE_NO_PIP;
private int mPinnedTaskId = NONEXISTENT_TASK_ID;
private int mResizeAnimationDuration;
public static Pip create(
Context context,
PipBoundsState pipBoundsState,
PipBoundsAlgorithm pipBoundsAlgorithm,
PipTaskOrganizer pipTaskOrganizer,
PipTransitionController pipTransitionController,
TvPipMenuController tvPipMenuController,
PipMediaController pipMediaController,
TvPipNotificationController pipNotificationController,
TaskStackListenerImpl taskStackListener,
WindowManagerShellWrapper wmShell,
ShellExecutor mainExecutor) {
return new TvPipController(
context,
pipBoundsState,
pipBoundsAlgorithm,
pipTaskOrganizer,
pipTransitionController,
tvPipMenuController,
pipMediaController,
pipNotificationController,
taskStackListener,
wmShell,
mainExecutor).mImpl;
}
private TvPipController(
Context context,
PipBoundsState pipBoundsState,
PipBoundsAlgorithm pipBoundsAlgorithm,
PipTaskOrganizer pipTaskOrganizer,
PipTransitionController pipTransitionController,
TvPipMenuController tvPipMenuController,
PipMediaController pipMediaController,
TvPipNotificationController pipNotificationController,
TaskStackListenerImpl taskStackListener,
WindowManagerShellWrapper wmShell,
ShellExecutor mainExecutor) {
mContext = context;
mMainExecutor = mainExecutor;
mPipBoundsState = pipBoundsState;
mPipBoundsState.setDisplayId(context.getDisplayId());
mPipBoundsState.setDisplayLayout(new DisplayLayout(context, context.getDisplay()));
mPipBoundsAlgorithm = pipBoundsAlgorithm;
mPipMediaController = pipMediaController;
mPipNotificationController = pipNotificationController;
mPipNotificationController.setDelegate(this);
mTvPipMenuController = tvPipMenuController;
mTvPipMenuController.setDelegate(this);
mPipTaskOrganizer = pipTaskOrganizer;
pipTransitionController.registerPipTransitionCallback(this);
loadConfigurations();
registerTaskStackListenerCallback(taskStackListener);
registerWmShellPinnedStackListener(wmShell);
}
private void onConfigurationChanged(Configuration newConfig) {
if (DEBUG) Log.d(TAG, "onConfigurationChanged(), state=" + stateToName(mState));
if (isPipShown()) {
if (DEBUG) Log.d(TAG, " > closing Pip.");
closePip();
}
loadConfigurations();
mPipNotificationController.onConfigurationChanged(mContext);
}
/**
* Returns {@code true} if Pip is shown.
*/
private boolean isPipShown() {
return mState != STATE_NO_PIP;
}
/**
* Starts the process if bringing up the Pip menu if by issuing a command to move Pip
* task/window to the "Menu" position. We'll show the actual Menu UI (eg. actions) once the Pip
* task/window is properly positioned in {@link #onPipTransitionFinished(ComponentName, int)}.
*/
@Override
public void showPictureInPictureMenu() {
if (DEBUG) Log.d(TAG, "showPictureInPictureMenu(), state=" + stateToName(mState));
if (mState != STATE_PIP) {
if (DEBUG) Log.d(TAG, " > cannot open Menu from the current state.");
return;
}
setState(STATE_PIP_MENU);
resizePinnedStack(STATE_PIP_MENU);
}
/**
* Moves Pip window to its "normal" position.
*/
@Override
public void movePipToNormalPosition() {
if (DEBUG) Log.d(TAG, "movePipToNormalPosition(), state=" + stateToName(mState));
setState(STATE_PIP);
resizePinnedStack(STATE_PIP);
}
/**
* Opens the "Pip-ed" Activity fullscreen.
*/
@Override
public void movePipToFullscreen() {
if (DEBUG) Log.d(TAG, "movePipToFullscreen(), state=" + stateToName(mState));
mPipTaskOrganizer.exitPip(mResizeAnimationDuration);
onPipDisappeared();
}
/**
* Closes Pip window.
*/
@Override
public void closePip() {
if (DEBUG) Log.d(TAG, "closePip(), state=" + stateToName(mState));
removeTask(mPinnedTaskId);
onPipDisappeared();
}
/**
* Resizes the Pip task/window to the appropriate size for the given state.
* This is a legacy API. Now we expect that the state argument passed to it should always match
* the current state of the Controller. If it does not match an {@link IllegalArgumentException}
* will be thrown. However, if the passed state does match - we'll determine the right bounds
* to the state and will move Pip task/window there.
*
* @param state the to determine the Pip bounds. IMPORTANT: should always match the current
* state of the Controller.
*/
private void resizePinnedStack(@State int state) {
if (state != mState) {
throw new IllegalArgumentException("The passed state should match the current state!");
}
if (DEBUG) Log.d(TAG, "resizePinnedStack() state=" + stateToName(mState));
final Rect newBounds;
switch (mState) {
case STATE_PIP_MENU:
newBounds = mPipBoundsState.getExpandedBounds();
break;
case STATE_PIP:
// Let PipBoundsAlgorithm figure out what the correct bounds are at the moment.
// Internally, it will get the "default" bounds from PipBoundsState and adjust them
// as needed to account for things like IME state (will query PipBoundsState for
// this information as well, so it's important to keep PipBoundsState up to date).
newBounds = mPipBoundsAlgorithm.getNormalBounds();
break;
case STATE_NO_PIP:
default:
return;
}
mPipTaskOrganizer.scheduleAnimateResizePip(newBounds, mResizeAnimationDuration, null);
}
private void registerSessionListenerForCurrentUser() {
mPipMediaController.registerSessionListenerForCurrentUser();
}
private void checkIfPinnedTaskAppeared() {
final TaskInfo pinnedTask = getPinnedTaskInfo();
if (DEBUG) Log.d(TAG, "checkIfPinnedTaskAppeared(), task=" + pinnedTask);
if (pinnedTask == null || pinnedTask.topActivity == null) return;
mPinnedTaskId = pinnedTask.taskId;
setState(STATE_PIP);
mPipMediaController.onActivityPinned();
mPipNotificationController.show(pinnedTask.topActivity.getPackageName());
}
private void checkIfPinnedTaskIsGone() {
if (DEBUG) Log.d(TAG, "onTaskStackChanged()");
if (isPipShown() && getPinnedTaskInfo() == null) {
Log.w(TAG, "Pinned task is gone.");
onPipDisappeared();
}
}
private void onPipDisappeared() {
if (DEBUG) Log.d(TAG, "onPipDisappeared() state=" + stateToName(mState));
mPipNotificationController.dismiss();
mTvPipMenuController.hideMenu();
setState(STATE_NO_PIP);
mPinnedTaskId = NONEXISTENT_TASK_ID;
}
@Override
public void onPipTransitionStarted(int direction, Rect pipBounds) {
if (DEBUG) Log.d(TAG, "onPipTransition_Started(), state=" + stateToName(mState));
}
@Override
public void onPipTransitionCanceled(int direction) {
if (DEBUG) Log.d(TAG, "onPipTransition_Canceled(), state=" + stateToName(mState));
}
@Override
public void onPipTransitionFinished(int direction) {
if (DEBUG) Log.d(TAG, "onPipTransition_Finished(), state=" + stateToName(mState));
if (mState == STATE_PIP_MENU) {
if (DEBUG) Log.d(TAG, " > show menu");
mTvPipMenuController.showMenu();
}
}
private void setState(@State int state) {
if (DEBUG) {
Log.d(TAG, "setState(), state=" + stateToName(state) + ", prev="
+ stateToName(mState));
}
mState = state;
}
private void loadConfigurations() {
final Resources res = mContext.getResources();
mResizeAnimationDuration = res.getInteger(R.integer.config_pipResizeAnimationDuration);
// "Cache" bounds for the Pip menu as "expanded" bounds in PipBoundsState. We'll refer back
// to this value in resizePinnedStack(), when we are adjusting Pip task/window position for
// the menu.
mPipBoundsState.setExpandedBounds(
Rect.unflattenFromString(res.getString(R.string.pip_menu_bounds)));
}
private DisplayInfo getDisplayInfo() {
final DisplayInfo displayInfo = new DisplayInfo();
mContext.getDisplay().getDisplayInfo(displayInfo);
return displayInfo;
}
private void registerTaskStackListenerCallback(TaskStackListenerImpl taskStackListener) {
taskStackListener.addListener(new TaskStackListenerCallback() {
@Override
public void onActivityPinned(String packageName, int userId, int taskId, int stackId) {
checkIfPinnedTaskAppeared();
}
@Override
public void onTaskStackChanged() {
checkIfPinnedTaskIsGone();
}
@Override
public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
boolean homeTaskVisible, boolean clearedTask, boolean wasVisible) {
if (task.getWindowingMode() == WINDOWING_MODE_PINNED) {
if (DEBUG) Log.d(TAG, "onPinnedActivityRestartAttempt()");
// If the "Pip-ed" Activity is launched again by Launcher or intent, make it
// fullscreen.
movePipToFullscreen();
}
}
});
}
private void registerWmShellPinnedStackListener(WindowManagerShellWrapper wmShell) {
try {
wmShell.addPinnedStackListener(new PinnedStackListenerForwarder.PinnedTaskListener() {
@Override
public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
if (DEBUG) {
Log.d(TAG, "onImeVisibilityChanged(), visible=" + imeVisible
+ ", height=" + imeHeight);
}
if (imeVisible == mPipBoundsState.isImeShowing()
&& (!imeVisible || imeHeight == mPipBoundsState.getImeHeight())) {
// Nothing changed: either IME has been and remains invisible, or remains
// visible with the same height.
return;
}
mPipBoundsState.setImeVisibility(imeVisible, imeHeight);
// "Normal" Pip bounds may have changed, so if we are in the "normal" state,
// let's update the bounds.
if (mState == STATE_PIP) {
resizePinnedStack(STATE_PIP);
}
}
@Override
public void onMovementBoundsChanged(boolean fromImeAdjustment) {}
@Override
public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {
if (DEBUG) Log.d(TAG, "onActionsChanged()");
mTvPipMenuController.setAppActions(actions);
}
});
} catch (RemoteException e) {
Log.e(TAG, "Failed to register pinned stack listener", e);
}
}
private static TaskInfo getPinnedTaskInfo() {
if (DEBUG) Log.d(TAG, "getPinnedTaskInfo()");
try {
final TaskInfo taskInfo = ActivityTaskManager.getService().getRootTaskInfo(
WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
if (DEBUG) Log.d(TAG, " > taskInfo=" + taskInfo);
return taskInfo;
} catch (RemoteException e) {
Log.e(TAG, "getRootTaskInfo() failed", e);
return null;
}
}
private static void removeTask(int taskId) {
if (DEBUG) Log.d(TAG, "removeTask(), taskId=" + taskId);
try {
ActivityTaskManager.getService().removeTask(taskId);
} catch (Exception e) {
Log.e(TAG, "Atm.removeTask() failed", e);
}
}
private static String stateToName(@State int state) {
switch (state) {
case STATE_NO_PIP:
return "NO_PIP";
case STATE_PIP:
return "PIP";
case STATE_PIP_MENU:
return "PIP_MENU";
default:
// This can't happen.
throw new IllegalArgumentException("Unknown state " + state);
}
}
private class TvPipImpl implements Pip {
@Override
public void onConfigurationChanged(Configuration newConfig) {
mMainExecutor.execute(() -> {
TvPipController.this.onConfigurationChanged(newConfig);
});
}
@Override
public void registerSessionListenerForCurrentUser() {
mMainExecutor.execute(() -> {
TvPipController.this.registerSessionListenerForCurrentUser();
});
}
}
}