Support for a floating task window (behind a sysui flag) Creates a new package within shell for floating tasks along with a basic controller and interfaces for sysui and launcher to use to manage floating tasks. A single floating task is allowed at a time and they can only be created if a sysui flag is turned on. The floating task is backed by a TaskView, floats above all other content, and can be moved via a handle at the top of the view. The view sticks along the left and right edges of the screen, can be stashed (similar to PIP), and can be removed by dragging to a dismiss target in the middle of the screen (similar to PIP & bubbles). The tests included trigger the sysui flag on and off to ensure the flag behavior (i.e. that you can't create one unless the flag is on). Test: atest FloatingTaskControllerTest Bug: 237678727 Change-Id: I490c44685825e14166869b2cf7c2994ee0e30ba7
diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml index 5696b8d..0bc7085 100644 --- a/libs/WindowManager/Shell/res/values/dimen.xml +++ b/libs/WindowManager/Shell/res/values/dimen.xml
@@ -297,4 +297,28 @@ when the pinned stack size is overridden by app via minWidth/minHeight. --> <dimen name="overridable_minimal_size_pip_resizable_task">48dp</dimen> + + <!-- The size of the drag handle / menu shown along with a floating task. --> + <dimen name="floating_task_menu_size">32dp</dimen> + + <!-- The size of menu items in the floating task menu. --> + <dimen name="floating_task_menu_item_size">24dp</dimen> + + <!-- The horizontal margin of menu items in the floating task menu. --> + <dimen name="floating_task_menu_item_padding">5dp</dimen> + + <!-- The width of visible floating view region when stashed. --> + <dimen name="floating_task_stash_offset">32dp</dimen> + + <!-- The amount of elevation for a floating task. --> + <dimen name="floating_task_elevation">8dp</dimen> + + <!-- The amount of padding around the bottom and top of the task. --> + <dimen name="floating_task_vertical_padding">8dp</dimen> + + <!-- The normal size of the dismiss target. --> + <dimen name="floating_task_dismiss_circle_size">150dp</dimen> + + <!-- The smaller size of the dismiss target (shrinks when something is in the target). --> + <dimen name="floating_dismiss_circle_small">120dp</dimen> </resources>
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java index 976fba5..e0c782d 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java
@@ -49,6 +49,8 @@ @Override protected void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); + final Resources res = getResources(); + setBackground(res.getDrawable(R.drawable.dismiss_circle_background)); setViewSizes(); }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java index 7c3c14e..c9d523c 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java
@@ -24,6 +24,7 @@ import android.os.Handler; import android.os.SystemProperties; import android.view.IWindowManager; +import android.view.WindowManager; import com.android.internal.logging.UiEventLogger; import com.android.launcher3.icons.IconProvider; @@ -60,6 +61,8 @@ import com.android.wm.shell.displayareahelper.DisplayAreaHelper; import com.android.wm.shell.displayareahelper.DisplayAreaHelperController; import com.android.wm.shell.draganddrop.DragAndDropController; +import com.android.wm.shell.floating.FloatingTasks; +import com.android.wm.shell.floating.FloatingTasksController; import com.android.wm.shell.freeform.FreeformComponents; import com.android.wm.shell.fullscreen.FullscreenTaskListener; import com.android.wm.shell.hidedisplaycutout.HideDisplayCutoutController; @@ -572,6 +575,45 @@ } // + // Floating tasks + // + + @WMSingleton + @Provides + static Optional<FloatingTasks> provideFloatingTasks( + Optional<FloatingTasksController> floatingTaskController) { + return floatingTaskController.map((controller) -> controller.asFloatingTasks()); + } + + @WMSingleton + @Provides + static Optional<FloatingTasksController> provideFloatingTasksController(Context context, + ShellInit shellInit, + ShellController shellController, + ShellCommandHandler shellCommandHandler, + WindowManager windowManager, + ShellTaskOrganizer organizer, + TaskViewTransitions taskViewTransitions, + @ShellMainThread ShellExecutor mainExecutor, + @ShellBackgroundThread ShellExecutor bgExecutor, + SyncTransactionQueue syncQueue) { + if (FloatingTasksController.FLOATING_TASKS_ENABLED) { + return Optional.of(new FloatingTasksController(context, + shellInit, + shellController, + shellCommandHandler, + windowManager, + organizer, + taskViewTransitions, + mainExecutor, + bgExecutor, + syncQueue)); + } else { + return Optional.empty(); + } + } + + // // Starting window //
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingDismissController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingDismissController.java new file mode 100644 index 0000000..83a1734 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingDismissController.java
@@ -0,0 +1,259 @@ +/* + * 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 com.android.wm.shell.floating; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.dynamicanimation.animation.DynamicAnimation; + +import com.android.wm.shell.R; +import com.android.wm.shell.bubbles.DismissView; +import com.android.wm.shell.common.magnetictarget.MagnetizedObject; +import com.android.wm.shell.floating.views.FloatingTaskLayer; +import com.android.wm.shell.floating.views.FloatingTaskView; + +import java.util.Objects; + +/** + * Controls a floating dismiss circle that has a 'magnetic' field around it, causing views moved + * close to the target to be stuck to it unless moved out again. + */ +public class FloatingDismissController { + + /** Velocity required to dismiss the view without dragging it into the dismiss target. */ + private static final float FLING_TO_DISMISS_MIN_VELOCITY = 4000f; + /** + * Max velocity that the view can be moving through the target with to stick (i.e. if it's + * more than this velocity, it will pass through the target. + */ + private static final float STICK_TO_TARGET_MAX_X_VELOCITY = 2000f; + /** + * Percentage of the target width to use to determine if an object flung towards the target + * should dismiss (e.g. if target is 100px and this is set ot 2f, anything flung within a + * 200px-wide area around the target will be considered 'near' enough get dismissed). + */ + private static final float FLING_TO_TARGET_WIDTH_PERCENT = 2f; + /** Minimum alpha to apply to the view being dismissed when it is in the target. */ + private static final float DISMISS_VIEW_MIN_ALPHA = 0.6f; + /** Amount to scale down the view being dismissed when it is in the target. */ + private static final float DISMISS_VIEW_SCALE_DOWN_PERCENT = 0.15f; + + private Context mContext; + private FloatingTasksController mController; + private FloatingTaskLayer mParent; + + private DismissView mDismissView; + private ValueAnimator mDismissAnimator; + private View mViewBeingDismissed; + private float mDismissSizePercent; + private float mDismissSize; + + /** + * The currently magnetized object, which is being dragged and will be attracted to the magnetic + * dismiss target. + */ + private MagnetizedObject<View> mMagnetizedObject; + /** + * The MagneticTarget instance for our circular dismiss view. This is added to the + * MagnetizedObject instances for the view being dragged. + */ + private MagnetizedObject.MagneticTarget mMagneticTarget; + /** Magnet listener that handles animating and dismissing the view. */ + private MagnetizedObject.MagnetListener mFloatingViewMagnetListener; + + public FloatingDismissController(Context context, FloatingTasksController controller, + FloatingTaskLayer parent) { + mContext = context; + mController = controller; + mParent = parent; + updateSizes(); + createAndAddDismissView(); + + mDismissAnimator = ValueAnimator.ofFloat(1f, 0f); + mDismissAnimator.addUpdateListener(animation -> { + final float value = (float) animation.getAnimatedValue(); + if (mDismissView != null) { + mDismissView.setPivotX((mDismissView.getRight() - mDismissView.getLeft()) / 2f); + mDismissView.setPivotY((mDismissView.getBottom() - mDismissView.getTop()) / 2f); + final float scaleValue = Math.max(value, mDismissSizePercent); + mDismissView.getCircle().setScaleX(scaleValue); + mDismissView.getCircle().setScaleY(scaleValue); + } + if (mViewBeingDismissed != null) { + // TODO: alpha doesn't actually apply to taskView currently. + mViewBeingDismissed.setAlpha(Math.max(value, DISMISS_VIEW_MIN_ALPHA)); + mViewBeingDismissed.setScaleX(Math.max(value, DISMISS_VIEW_SCALE_DOWN_PERCENT)); + mViewBeingDismissed.setScaleY(Math.max(value, DISMISS_VIEW_SCALE_DOWN_PERCENT)); + } + }); + + mFloatingViewMagnetListener = new MagnetizedObject.MagnetListener() { + @Override + public void onStuckToTarget( + @NonNull MagnetizedObject.MagneticTarget target) { + animateDismissing(/* dismissing= */ true); + } + + @Override + public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, + float velX, float velY, boolean wasFlungOut) { + animateDismissing(/* dismissing= */ false); + mParent.onUnstuckFromTarget((FloatingTaskView) mViewBeingDismissed, velX, velY, + wasFlungOut); + } + + @Override + public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) { + doDismiss(); + } + }; + } + + /** Updates all the sizes used and applies them to the {@link DismissView}. */ + public void updateSizes() { + Resources res = mContext.getResources(); + mDismissSize = res.getDimensionPixelSize( + R.dimen.floating_task_dismiss_circle_size); + final float minDismissSize = res.getDimensionPixelSize( + R.dimen.floating_dismiss_circle_small); + mDismissSizePercent = minDismissSize / mDismissSize; + + if (mDismissView != null) { + mDismissView.updateResources(); + } + } + + /** Prepares the view being dragged to be magnetic. */ + public void setUpMagneticObject(View viewBeingDragged) { + mViewBeingDismissed = viewBeingDragged; + mMagnetizedObject = getMagnetizedView(viewBeingDragged); + mMagnetizedObject.clearAllTargets(); + mMagnetizedObject.addTarget(mMagneticTarget); + mMagnetizedObject.setMagnetListener(mFloatingViewMagnetListener); + } + + /** Shows or hides the dismiss target. */ + public void showDismiss(boolean show) { + if (show) { + mDismissView.show(); + } else { + mDismissView.hide(); + } + } + + /** Passes the MotionEvent to the magnetized object and returns true if it was consumed. */ + public boolean passEventToMagnetizedObject(MotionEvent event) { + return mMagnetizedObject != null && mMagnetizedObject.maybeConsumeMotionEvent(event); + } + + private void createAndAddDismissView() { + if (mDismissView != null) { + mParent.removeView(mDismissView); + } + mDismissView = new DismissView(mContext); + mDismissView.setTargetSizeResId(R.dimen.floating_task_dismiss_circle_size); + mDismissView.updateResources(); + mParent.addView(mDismissView); + + final float dismissRadius = mDismissSize; + // Save the MagneticTarget instance for the newly set up view - we'll add this to the + // MagnetizedObjects when the dismiss view gets shown. + mMagneticTarget = new MagnetizedObject.MagneticTarget( + mDismissView.getCircle(), (int) dismissRadius); + } + + private MagnetizedObject<View> getMagnetizedView(View v) { + if (mMagnetizedObject != null + && Objects.equals(mMagnetizedObject.getUnderlyingObject(), v)) { + // Same view being dragged, we can reuse the magnetic object. + return mMagnetizedObject; + } + MagnetizedObject<View> magnetizedView = new MagnetizedObject<View>( + mContext, + v, + DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y + ) { + @Override + public float getWidth(@NonNull View underlyingObject) { + return underlyingObject.getWidth(); + } + + @Override + public float getHeight(@NonNull View underlyingObject) { + return underlyingObject.getHeight(); + } + + @Override + public void getLocationOnScreen(@NonNull View underlyingObject, + @NonNull int[] loc) { + loc[0] = (int) underlyingObject.getTranslationX(); + loc[1] = (int) underlyingObject.getTranslationY(); + } + }; + magnetizedView.setHapticsEnabled(true); + magnetizedView.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY); + magnetizedView.setStickToTargetMaxXVelocity(STICK_TO_TARGET_MAX_X_VELOCITY); + magnetizedView.setFlingToTargetWidthPercent(FLING_TO_TARGET_WIDTH_PERCENT); + return magnetizedView; + } + + /** Animates the dismiss treatment on the view being dismissed. */ + private void animateDismissing(boolean shouldDismiss) { + if (mViewBeingDismissed == null) { + return; + } + if (shouldDismiss) { + mDismissAnimator.removeAllListeners(); + mDismissAnimator.start(); + } else { + mDismissAnimator.removeAllListeners(); + mDismissAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + resetDismissAnimator(); + } + }); + mDismissAnimator.reverse(); + } + } + + /** Actually dismisses the view. */ + private void doDismiss() { + mDismissView.hide(); + mController.removeTask(); + resetDismissAnimator(); + mViewBeingDismissed = null; + } + + private void resetDismissAnimator() { + mDismissAnimator.removeAllListeners(); + mDismissAnimator.cancel(); + if (mDismissView != null) { + mDismissView.cancelAnimators(); + mDismissView.getCircle().setScaleX(1f); + mDismissView.getCircle().setScaleY(1f); + } + } +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasks.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasks.java new file mode 100644 index 0000000..9356660 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasks.java
@@ -0,0 +1,41 @@ +/* + * 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 com.android.wm.shell.floating; + +import android.content.Intent; + +import com.android.wm.shell.common.annotations.ExternalThread; + +/** + * Interface to interact with floating tasks. + */ +@ExternalThread +public interface FloatingTasks { + + /** + * Shows, stashes, or un-stashes the floating task depending on state: + * - If there is no floating task for this intent, it shows the task for the provided intent. + * - If there is a floating task for this intent, but it's stashed, this un-stashes it. + * - If there is a floating task for this intent, and it's not stashed, this stashes it. + */ + void showOrSetStashed(Intent intent); + + /** Returns a binder that can be passed to an external process to manipulate FloatingTasks. */ + default IFloatingTasks createExternalInterface() { + return null; + } +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasksController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasksController.java new file mode 100644 index 0000000..c79b9b8 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/FloatingTasksController.java
@@ -0,0 +1,430 @@ +/* + * 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 com.android.wm.shell.floating; + +import static android.app.ActivityTaskManager.INVALID_TASK_ID; +import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + +import static com.android.wm.shell.common.ExecutorUtils.executeRemoteCallWithTaskPermission; +import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_FLOATING_APPS; + +import android.annotation.Nullable; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.res.Configuration; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.os.SystemProperties; +import android.view.ViewGroup; +import android.view.WindowManager; + +import androidx.annotation.BinderThread; +import androidx.annotation.VisibleForTesting; + +import com.android.internal.protolog.common.ProtoLog; +import com.android.wm.shell.ShellTaskOrganizer; +import com.android.wm.shell.TaskViewTransitions; +import com.android.wm.shell.common.RemoteCallable; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.SyncTransactionQueue; +import com.android.wm.shell.common.annotations.ExternalThread; +import com.android.wm.shell.common.annotations.ShellBackgroundThread; +import com.android.wm.shell.common.annotations.ShellMainThread; +import com.android.wm.shell.floating.views.FloatingTaskLayer; +import com.android.wm.shell.floating.views.FloatingTaskView; +import com.android.wm.shell.sysui.ConfigurationChangeListener; +import com.android.wm.shell.sysui.ShellCommandHandler; +import com.android.wm.shell.sysui.ShellController; +import com.android.wm.shell.sysui.ShellInit; + +import java.io.PrintWriter; +import java.util.Objects; + +/** + * Entry point for creating and managing floating tasks. + * + * A single window layer is added and the task(s) are displayed using a {@link FloatingTaskView} + * within that window. + * + * Currently optimized for a single task. Multiple tasks are not supported. + */ +public class FloatingTasksController implements RemoteCallable<FloatingTasksController>, + ConfigurationChangeListener { + + private static final String TAG = FloatingTasksController.class.getSimpleName(); + + public static final boolean FLOATING_TASKS_ENABLED = + SystemProperties.getBoolean("persist.wm.debug.floating_tasks", false); + + @VisibleForTesting + static final int SMALLEST_SCREEN_WIDTH_DP_TO_BE_TABLET = 600; + + // Only used for testing + private Configuration mConfig; + private boolean mFloatingTasksEnabledForTests; + + private FloatingTaskImpl mImpl = new FloatingTaskImpl(); + private Context mContext; + private ShellController mShellController; + private ShellCommandHandler mShellCommandHandler; + private WindowManager mWindowManager; + private ShellTaskOrganizer mTaskOrganizer; + private TaskViewTransitions mTaskViewTransitions; + private @ShellMainThread ShellExecutor mMainExecutor; + // TODO: mBackgroundThread is not used but we'll probs need it eventually? + private @ShellBackgroundThread ShellExecutor mBackgroundThread; + private SyncTransactionQueue mSyncQueue; + + private boolean mIsFloatingLayerAdded; + private FloatingTaskLayer mFloatingTaskLayer; + private final Point mLastPosition = new Point(-1, -1); + + private Task mTask; + + // Simple class to hold onto info for intent or shortcut based tasks. + public static class Task { + public int taskId = INVALID_TASK_ID; + @Nullable + public Intent intent; + @Nullable + public ShortcutInfo info; + @Nullable + public FloatingTaskView floatingView; + } + + public FloatingTasksController(Context context, + ShellInit shellInit, + ShellController shellController, + ShellCommandHandler shellCommandHandler, + WindowManager windowManager, + ShellTaskOrganizer organizer, + TaskViewTransitions transitions, + @ShellMainThread ShellExecutor mainExecutor, + @ShellBackgroundThread ShellExecutor bgExceutor, + SyncTransactionQueue syncTransactionQueue) { + mContext = context; + mShellController = shellController; + mShellCommandHandler = shellCommandHandler; + mWindowManager = windowManager; + mTaskOrganizer = organizer; + mTaskViewTransitions = transitions; + mMainExecutor = mainExecutor; + mBackgroundThread = bgExceutor; + mSyncQueue = syncTransactionQueue; + if (isFloatingTasksEnabled()) { + shellInit.addInitCallback(this::onInit, this); + } + mShellCommandHandler.addDumpCallback(this::dump, this); + } + + protected void onInit() { + mShellController.addConfigurationChangeListener(this); + } + + /** Only used for testing. */ + @VisibleForTesting + void setConfig(Configuration config) { + mConfig = config; + } + + /** Only used for testing. */ + @VisibleForTesting + void setFloatingTasksEnabled(boolean enabled) { + mFloatingTasksEnabledForTests = enabled; + } + + /** Whether the floating layer is available. */ + boolean isFloatingLayerAvailable() { + Configuration config = mConfig == null + ? mContext.getResources().getConfiguration() + : mConfig; + return config.smallestScreenWidthDp >= SMALLEST_SCREEN_WIDTH_DP_TO_BE_TABLET; + } + + /** Whether floating tasks are enabled. */ + boolean isFloatingTasksEnabled() { + return FLOATING_TASKS_ENABLED || mFloatingTasksEnabledForTests; + } + + @Override + public void onThemeChanged() { + if (mIsFloatingLayerAdded) { + mFloatingTaskLayer.updateSizes(); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + // TODO: probably other stuff here to do (e.g. handle rotation) + if (mIsFloatingLayerAdded) { + mFloatingTaskLayer.updateSizes(); + } + } + + /** Returns false if the task shouldn't be shown. */ + private boolean canShowTask(Intent intent) { + ProtoLog.d(WM_SHELL_FLOATING_APPS, "canShowTask -- %s", intent); + if (!isFloatingTasksEnabled() || !isFloatingLayerAvailable()) return false; + if (intent == null) { + ProtoLog.e(WM_SHELL_FLOATING_APPS, "canShowTask given null intent, doing nothing"); + return false; + } + return true; + } + + /** + * Shows, stashes, or un-stashes the floating task depending on state: + * - If there is no floating task for this intent, it shows this the provided task. + * - If there is a floating task for this intent, but it's stashed, this un-stashes it. + * - If there is a floating task for this intent, and it's not stashed, this stashes it. + */ + public void showOrSetStashed(Intent intent) { + if (!canShowTask(intent)) return; + + addFloatingLayer(); + + if (isTaskAttached(mTask) && intent.filterEquals(mTask.intent)) { + // The task is already added, toggle the stash state. + mFloatingTaskLayer.setStashed(mTask, !mTask.floatingView.isStashed()); + return; + } + + // If we're here it's either a new or different task + showNewTask(intent); + } + + /** + * Shows a floating task with the provided intent. + * If the same task is present it will un-stash it or do nothing if it is already un-stashed. + * Removes any other floating tasks that might exist. + */ + public void showTask(Intent intent) { + if (!canShowTask(intent)) return; + + addFloatingLayer(); + + if (isTaskAttached(mTask) && intent.filterEquals(mTask.intent)) { + // The task is already added, show it if it's stashed. + if (mTask.floatingView.isStashed()) { + mFloatingTaskLayer.setStashed(mTask, false); + } + return; + } + showNewTask(intent); + } + + private void showNewTask(Intent intent) { + if (mTask != null && !intent.filterEquals(mTask.intent)) { + mFloatingTaskLayer.removeAllTaskViews(); + mTask.floatingView.cleanUpTaskView(); + mTask = null; + } + + FloatingTaskView ftv = new FloatingTaskView(mContext, this); + ftv.createTaskView(mContext, mTaskOrganizer, mTaskViewTransitions, mSyncQueue); + + mTask = new Task(); + mTask.floatingView = ftv; + mTask.intent = intent; + + // Add & start the task. + mFloatingTaskLayer.addTask(mTask); + ProtoLog.d(WM_SHELL_FLOATING_APPS, "showNewTask, startingIntent: %s", intent); + mTask.floatingView.startTask(mMainExecutor, mTask); + } + + /** + * Removes the task and cleans up the view. + */ + public void removeTask() { + if (mTask != null) { + ProtoLog.d(WM_SHELL_FLOATING_APPS, "Removing task with id=%d", mTask.taskId); + + if (mTask.floatingView != null) { + // TODO: animate it + mFloatingTaskLayer.removeView(mTask.floatingView); + mTask.floatingView.cleanUpTaskView(); + } + removeFloatingLayer(); + } + } + + /** + * Whether there is a floating task and if it is stashed. + */ + public boolean isStashed() { + return isTaskAttached(mTask) && mTask.floatingView.isStashed(); + } + + /** + * If a floating task exists, this sets whether it is stashed and animates if needed. + */ + public void setStashed(boolean shouldStash) { + if (mTask != null && mTask.floatingView != null && mIsFloatingLayerAdded) { + mFloatingTaskLayer.setStashed(mTask, shouldStash); + } + } + + /** + * Saves the last position the floating task was in so that it can be put there again. + */ + public void setLastPosition(int x, int y) { + mLastPosition.set(x, y); + } + + /** + * Returns the last position the floating task was in. + */ + public Point getLastPosition() { + return mLastPosition; + } + + /** + * Whether the provided task has a view that's attached to the floating layer. + */ + private boolean isTaskAttached(Task t) { + return t != null && t.floatingView != null + && mIsFloatingLayerAdded + && mFloatingTaskLayer.getTaskViewCount() > 0 + && Objects.equals(mFloatingTaskLayer.getFirstTaskView(), t.floatingView); + } + + // TODO: when this is added, if there are bubbles, they get hidden? Is only one layer of this + // type allowed? Bubbles & floating tasks should probably be in the same layer to reduce + // # of windows. + private void addFloatingLayer() { + if (mIsFloatingLayerAdded) { + return; + } + + mFloatingTaskLayer = new FloatingTaskLayer(mContext, this, mWindowManager); + + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + PixelFormat.TRANSLUCENT + ); + params.setTrustedOverlay(); + params.setFitInsetsTypes(0); + params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + params.setTitle("FloatingTaskLayer"); + params.packageName = mContext.getPackageName(); + params.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + params.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; + + try { + mIsFloatingLayerAdded = true; + mWindowManager.addView(mFloatingTaskLayer, params); + } catch (IllegalStateException e) { + // This means the floating layer has already been added which shouldn't happen. + e.printStackTrace(); + } + } + + private void removeFloatingLayer() { + if (!mIsFloatingLayerAdded) { + return; + } + try { + mIsFloatingLayerAdded = false; + if (mFloatingTaskLayer != null) { + mWindowManager.removeView(mFloatingTaskLayer); + } + } catch (IllegalArgumentException e) { + // This means the floating layer has already been removed which shouldn't happen. + e.printStackTrace(); + } + } + + /** + * Description of current floating task state. + */ + private void dump(PrintWriter pw, String prefix) { + pw.println("FloatingTaskController state:"); + pw.print(" isFloatingLayerAvailable= "); pw.println(isFloatingLayerAvailable()); + pw.print(" isFloatingTasksEnabled= "); pw.println(isFloatingTasksEnabled()); + pw.print(" mIsFloatingLayerAdded= "); pw.println(mIsFloatingLayerAdded); + pw.print(" mLastPosition= "); pw.println(mLastPosition); + pw.println(); + } + + /** Returns the {@link FloatingTasks} implementation. */ + public FloatingTasks asFloatingTasks() { + return mImpl; + } + + @Override + public Context getContext() { + return mContext; + } + + @Override + public ShellExecutor getRemoteCallExecutor() { + return mMainExecutor; + } + + /** + * The interface for calls from outside the shell, within the host process. + */ + @ExternalThread + private class FloatingTaskImpl implements FloatingTasks { + private IFloatingTasksImpl mIFloatingTasks; + + @Override + public void showOrSetStashed(Intent intent) { + mMainExecutor.execute(() -> FloatingTasksController.this.showOrSetStashed(intent)); + } + + @Override + public IFloatingTasks createExternalInterface() { + if (mIFloatingTasks != null) { + mIFloatingTasks.invalidate(); + } + mIFloatingTasks = new IFloatingTasksImpl(FloatingTasksController.this); + return mIFloatingTasks; + } + } + + /** + * The interface for calls from outside the host process. + */ + @BinderThread + private static class IFloatingTasksImpl extends IFloatingTasks.Stub { + private FloatingTasksController mController; + + IFloatingTasksImpl(FloatingTasksController controller) { + mController = controller; + } + + /** + * Invalidates this instance, preventing future calls from updating the controller. + */ + void invalidate() { + mController = null; + } + + public void showTask(Intent intent) { + executeRemoteCallWithTaskPermission(mController, "showTask", + (controller) -> controller.showTask(intent)); + } + } +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/IFloatingTasks.aidl b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/IFloatingTasks.aidl new file mode 100644 index 0000000..f79ca10 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/IFloatingTasks.aidl
@@ -0,0 +1,28 @@ +/* + * 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 com.android.wm.shell.floating; + +import android.content.Intent; + +/** + * Interface that is exposed to remote callers to manipulate floating task features. + */ +interface IFloatingTasks { + + void showTask(in Intent intent) = 1; + +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingMenuView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingMenuView.java new file mode 100644 index 0000000..c922109 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingMenuView.java
@@ -0,0 +1,73 @@ +/* + * 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 com.android.wm.shell.floating.views; + +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import com.android.wm.shell.R; + +/** + * Displays the menu items for a floating task view (e.g. close). + */ +public class FloatingMenuView extends LinearLayout { + + private int mItemSize; + private int mItemMargin; + + public FloatingMenuView(Context context) { + super(context); + setOrientation(LinearLayout.HORIZONTAL); + setGravity(Gravity.CENTER); + + mItemSize = context.getResources().getDimensionPixelSize( + R.dimen.floating_task_menu_item_size); + mItemMargin = context.getResources().getDimensionPixelSize( + R.dimen.floating_task_menu_item_padding); + } + + /** Adds a clickable item to the menu bar. Items are ordered as added. */ + public void addMenuItem(@Nullable Drawable drawable, View.OnClickListener listener) { + ImageView itemView = new ImageView(getContext()); + itemView.setScaleType(ImageView.ScaleType.CENTER); + if (drawable != null) { + itemView.setImageDrawable(drawable); + } + LinearLayout.LayoutParams lp = new LayoutParams(mItemSize, + ViewGroup.LayoutParams.MATCH_PARENT); + lp.setMarginStart(mItemMargin); + lp.setMarginEnd(mItemMargin); + addView(itemView, lp); + + itemView.setOnClickListener(listener); + } + + /** + * The menu extends past the top of the TaskView because of the rounded corners. This means + * to center content in the menu we must subtract the radius (i.e. the amount of space covered + * by TaskView). + */ + public void setCornerRadius(float radius) { + setPadding(getPaddingLeft(), getPaddingTop(), getPaddingRight(), (int) radius); + } +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskLayer.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskLayer.java new file mode 100644 index 0000000..16dab24 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskLayer.java
@@ -0,0 +1,687 @@ +/* + * 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 com.android.wm.shell.floating.views; + +import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_FLOATING_APPS; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Insets; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.Region; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewPropertyAnimator; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.WindowMetrics; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.dynamicanimation.animation.DynamicAnimation; +import androidx.dynamicanimation.animation.FlingAnimation; + +import com.android.internal.protolog.common.ProtoLog; +import com.android.wm.shell.R; +import com.android.wm.shell.floating.FloatingDismissController; +import com.android.wm.shell.floating.FloatingTasksController; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * This is the layout that {@link FloatingTaskView}s are contained in. It handles input and + * movement of the task views. + */ +public class FloatingTaskLayer extends FrameLayout + implements ViewTreeObserver.OnComputeInternalInsetsListener { + + private static final String TAG = FloatingTaskLayer.class.getSimpleName(); + + /** How big to make the task view based on screen width of the largest size. */ + private static final float START_SIZE_WIDTH_PERCENT = 0.33f; + /** Min fling velocity required to move the view from one side of the screen to the other. */ + private static final float ESCAPE_VELOCITY = 750f; + /** Amount of friction to apply to fling animations. */ + private static final float FLING_FRICTION = 1.9f; + + private final FloatingTasksController mController; + private final FloatingDismissController mDismissController; + private final WindowManager mWindowManager; + private final TouchHandlerImpl mTouchHandler; + + private final Region mTouchableRegion = new Region(); + private final Rect mPositionRect = new Rect(); + private final Point mDefaultStartPosition = new Point(); + private final Point mTaskViewSize = new Point(); + private WindowInsets mWindowInsets; + private int mVerticalPadding; + private int mOverhangWhenStashed; + + private final List<Rect> mSystemGestureExclusionRects = Collections.singletonList(new Rect()); + private ViewTreeObserver.OnDrawListener mSystemGestureExclusionListener = + this::updateSystemGestureExclusion; + + /** Interface allowing something to handle the touch events going to a task. */ + interface FloatingTaskTouchHandler { + void onDown(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, + float viewInitialX, float viewInitialY); + + void onMove(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, + float dx, float dy); + + void onUp(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, + float dx, float dy, float velX, float velY); + + void onClick(@NonNull FloatingTaskView v); + } + + public FloatingTaskLayer(Context context, + FloatingTasksController controller, + WindowManager windowManager) { + super(context); + // TODO: Why is this necessary? Without it FloatingTaskView does not render correctly. + setBackgroundColor(Color.argb(0, 0, 0, 0)); + + mController = controller; + mWindowManager = windowManager; + updateSizes(); + + // TODO: Might make sense to put dismiss controller in the touch handler since that's the + // main user of dismiss controller. + mDismissController = new FloatingDismissController(context, mController, this); + mTouchHandler = new TouchHandlerImpl(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + getViewTreeObserver().addOnComputeInternalInsetsListener(this); + getViewTreeObserver().addOnDrawListener(mSystemGestureExclusionListener); + setOnApplyWindowInsetsListener((view, windowInsets) -> { + if (!windowInsets.equals(mWindowInsets)) { + mWindowInsets = windowInsets; + updateSizes(); + } + return windowInsets; + }); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + getViewTreeObserver().removeOnComputeInternalInsetsListener(this); + getViewTreeObserver().removeOnDrawListener(mSystemGestureExclusionListener); + } + + @Override + public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { + inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); + mTouchableRegion.setEmpty(); + getTouchableRegion(mTouchableRegion); + inoutInfo.touchableRegion.set(mTouchableRegion); + } + + /** Adds a floating task to the layout. */ + public void addTask(FloatingTasksController.Task task) { + if (task.floatingView == null) return; + + task.floatingView.setTouchHandler(mTouchHandler); + addView(task.floatingView, new LayoutParams(mTaskViewSize.x, mTaskViewSize.y)); + updateTaskViewPosition(task.floatingView); + } + + /** Animates the stashed state of the provided task, if it's part of the floating layer. */ + public void setStashed(FloatingTasksController.Task task, boolean shouldStash) { + if (task.floatingView != null && task.floatingView.getParent() == this) { + mTouchHandler.stashTaskView(task.floatingView, shouldStash); + } + } + + /** Removes all {@link FloatingTaskView} from the layout. */ + public void removeAllTaskViews() { + int childCount = getChildCount(); + ArrayList<View> viewsToRemove = new ArrayList<>(); + for (int i = 0; i < childCount; i++) { + if (getChildAt(i) instanceof FloatingTaskView) { + viewsToRemove.add(getChildAt(i)); + } + } + for (View v : viewsToRemove) { + removeView(v); + } + } + + /** Returns the number of task views in the layout. */ + public int getTaskViewCount() { + int taskViewCount = 0; + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + if (getChildAt(i) instanceof FloatingTaskView) { + taskViewCount++; + } + } + return taskViewCount; + } + + /** + * Called when the task view is un-stuck from the dismiss target. + * @param v the task view being moved. + * @param velX the x velocity of the motion event. + * @param velY the y velocity of the motion event. + * @param wasFlungOut true if the user flung the task view out of the dismiss target (i.e. there + * was an 'up' event), otherwise the user is still dragging. + */ + public void onUnstuckFromTarget(FloatingTaskView v, float velX, float velY, + boolean wasFlungOut) { + mTouchHandler.onUnstuckFromTarget(v, velX, velY, wasFlungOut); + } + + /** + * Updates dimensions and applies them to any task views. + */ + public void updateSizes() { + if (mDismissController != null) { + mDismissController.updateSizes(); + } + + mOverhangWhenStashed = getResources().getDimensionPixelSize( + R.dimen.floating_task_stash_offset); + mVerticalPadding = getResources().getDimensionPixelSize( + R.dimen.floating_task_vertical_padding); + + WindowMetrics windowMetrics = mWindowManager.getCurrentWindowMetrics(); + WindowInsets windowInsets = windowMetrics.getWindowInsets(); + Insets insets = windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars() + | WindowInsets.Type.statusBars() + | WindowInsets.Type.displayCutout()); + Rect bounds = windowMetrics.getBounds(); + mPositionRect.set(bounds.left + insets.left, + bounds.top + insets.top + mVerticalPadding, + bounds.right - insets.right, + bounds.bottom - insets.bottom - mVerticalPadding); + + int taskViewWidth = Math.max(bounds.height(), bounds.width()); + int taskViewHeight = Math.min(bounds.height(), bounds.width()); + taskViewHeight = taskViewHeight - (insets.top + insets.bottom + (mVerticalPadding * 2)); + mTaskViewSize.set((int) (taskViewWidth * START_SIZE_WIDTH_PERCENT), taskViewHeight); + mDefaultStartPosition.set(mPositionRect.left, mPositionRect.top); + + // Update existing views + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + if (getChildAt(i) instanceof FloatingTaskView) { + FloatingTaskView child = (FloatingTaskView) getChildAt(i); + LayoutParams lp = (LayoutParams) child.getLayoutParams(); + lp.width = mTaskViewSize.x; + lp.height = mTaskViewSize.y; + child.setLayoutParams(lp); + updateTaskViewPosition(child); + } + } + } + + /** Returns the first floating task view in the layout. (Currently only ever 1 view). */ + @Nullable + public FloatingTaskView getFirstTaskView() { + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (child instanceof FloatingTaskView) { + return (FloatingTaskView) child; + } + } + return null; + } + + private void updateTaskViewPosition(FloatingTaskView floatingView) { + Point lastPosition = mController.getLastPosition(); + if (lastPosition.x == -1 && lastPosition.y == -1) { + floatingView.setX(mDefaultStartPosition.x); + floatingView.setY(mDefaultStartPosition.y); + } else { + floatingView.setX(lastPosition.x); + floatingView.setY(lastPosition.y); + } + if (mTouchHandler.isStashedPosition(floatingView)) { + floatingView.setStashed(true); + } + floatingView.updateLocation(); + } + + /** + * Updates the area of the screen that shouldn't allow the back gesture due to the placement + * of task view (i.e. when task view is stashed on an edge, tapping or swiping that edge would + * un-stash the task view instead of performing the back gesture). + */ + private void updateSystemGestureExclusion() { + Rect excludeZone = mSystemGestureExclusionRects.get(0); + FloatingTaskView floatingTaskView = getFirstTaskView(); + if (floatingTaskView != null && floatingTaskView.isStashed()) { + excludeZone.set(floatingTaskView.getLeft(), + floatingTaskView.getTop(), + floatingTaskView.getRight(), + floatingTaskView.getBottom()); + excludeZone.offset((int) (floatingTaskView.getTranslationX()), + (int) (floatingTaskView.getTranslationY())); + setSystemGestureExclusionRects(mSystemGestureExclusionRects); + } else { + excludeZone.setEmpty(); + setSystemGestureExclusionRects(Collections.emptyList()); + } + } + + /** + * Fills in the touchable region for floating windows. This is used by WindowManager to + * decide which touch events go to the floating windows. + */ + private void getTouchableRegion(Region outRegion) { + int childCount = getChildCount(); + Rect temp = new Rect(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (child instanceof FloatingTaskView) { + child.getBoundsOnScreen(temp); + outRegion.op(temp, Region.Op.UNION); + } + } + } + + /** + * Implementation of the touch handler. Animates the task view based on touch events. + */ + private class TouchHandlerImpl implements FloatingTaskTouchHandler { + /** + * The view can be stashed by swiping it towards the current edge or moving it there. If + * the view gets moved in a way that is not one of these gestures, this is flipped to false. + */ + private boolean mCanStash = true; + /** + * This is used to indicate that the view has been un-stuck from the dismiss target and + * needs to spring to the current touch location. + */ + // TODO: implement this behavior + private boolean mSpringToTouchOnNextMotionEvent = false; + + private ArrayList<FlingAnimation> mFlingAnimations; + private ViewPropertyAnimator mViewPropertyAnimation; + + private float mViewInitialX; + private float mViewInitialY; + + private float[] mMinMax = new float[2]; + + @Override + public void onDown(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, float viewInitialX, + float viewInitialY) { + mCanStash = true; + mViewInitialX = viewInitialX; + mViewInitialY = viewInitialY; + mDismissController.setUpMagneticObject(v); + mDismissController.passEventToMagnetizedObject(ev); + } + + @Override + public void onMove(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, + float dx, float dy) { + // Shows the magnetic dismiss target if needed. + mDismissController.showDismiss(/* show= */ true); + + // Send it to magnetic target first. + if (mDismissController.passEventToMagnetizedObject(ev)) { + v.setStashed(false); + mCanStash = true; + + return; + } + + // If we're here magnetic target didn't want it so move as per normal. + + v.setTranslationX(capX(v, mViewInitialX + dx, /* isMoving= */ true)); + v.setTranslationY(capY(v, mViewInitialY + dy)); + if (v.isStashed()) { + // Check if we've moved far enough to be not stashed. + final float centerX = mPositionRect.centerX() - (v.getWidth() / 2f); + final boolean viewInitiallyOnLeftSide = mViewInitialX < centerX; + if (viewInitiallyOnLeftSide) { + if (v.getTranslationX() > mPositionRect.left) { + v.setStashed(false); + mCanStash = true; + } + } else if (v.getTranslationX() + v.getWidth() < mPositionRect.right) { + v.setStashed(false); + mCanStash = true; + } + } + } + + // Reference for math / values: StackAnimationController#flingStackThenSpringToEdge. + // TODO clean up the code here, pretty hard to comprehend + // TODO code here doesn't work the best when in portrait (e.g. can't fling up/down on edges) + @Override + public void onUp(@NonNull FloatingTaskView v, @NonNull MotionEvent ev, + float dx, float dy, float velX, float velY) { + + // Send it to magnetic target first. + if (mDismissController.passEventToMagnetizedObject(ev)) { + v.setStashed(false); + return; + } + mDismissController.showDismiss(/* show= */ false); + + // If we're here magnetic target didn't want it so handle up as per normal. + + final float x = capX(v, mViewInitialX + dx, /* isMoving= */ false); + final float centerX = mPositionRect.centerX(); + final boolean viewInitiallyOnLeftSide = mViewInitialX + v.getWidth() < centerX; + final boolean viewOnLeftSide = x + v.getWidth() < centerX; + final boolean isFling = Math.abs(velX) > ESCAPE_VELOCITY; + final boolean isFlingLeft = isFling && velX < ESCAPE_VELOCITY; + // TODO: check velX here sometimes it doesn't stash on move when I think it should + final boolean shouldStashFromMove = + (velX < 0 && v.getTranslationX() < mPositionRect.left) + || (velX > 0 + && v.getTranslationX() + v.getWidth() > mPositionRect.right); + final boolean shouldStashFromFling = viewInitiallyOnLeftSide == viewOnLeftSide + && isFling + && ((viewOnLeftSide && velX < ESCAPE_VELOCITY) + || (!viewOnLeftSide && velX > ESCAPE_VELOCITY)); + final boolean shouldStash = mCanStash && (shouldStashFromFling || shouldStashFromMove); + + ProtoLog.d(WM_SHELL_FLOATING_APPS, + "shouldStash=%s shouldStashFromFling=%s shouldStashFromMove=%s" + + " viewInitiallyOnLeftSide=%s viewOnLeftSide=%s isFling=%s velX=%f" + + " isStashed=%s", shouldStash, shouldStashFromFling, shouldStashFromMove, + viewInitiallyOnLeftSide, viewOnLeftSide, isFling, velX, v.isStashed()); + + if (v.isStashed()) { + mMinMax[0] = viewOnLeftSide + ? mPositionRect.left - v.getWidth() + mOverhangWhenStashed + : mPositionRect.right - v.getWidth(); + mMinMax[1] = viewOnLeftSide + ? mPositionRect.left + : mPositionRect.right - mOverhangWhenStashed; + } else { + populateMinMax(v, viewOnLeftSide, shouldStash, mMinMax); + } + + boolean movingLeft = isFling ? isFlingLeft : viewOnLeftSide; + float destinationRelativeX = movingLeft + ? mMinMax[0] + : mMinMax[1]; + + // TODO: why is this necessary / when does this happen? + if (mMinMax[1] < v.getTranslationX()) { + mMinMax[1] = v.getTranslationX(); + } + if (v.getTranslationX() < mMinMax[0]) { + mMinMax[0] = v.getTranslationX(); + } + + // Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity + // so that it'll make it all the way to the side of the screen. + final float minimumVelocityToReachEdge = + getMinimumVelocityToReachEdge(v, destinationRelativeX); + final float startXVelocity = movingLeft + ? Math.min(minimumVelocityToReachEdge, velX) + : Math.max(minimumVelocityToReachEdge, velX); + + cancelAnyAnimations(v); + + mFlingAnimations = getAnimationForUpEvent(v, shouldStash, + startXVelocity, mMinMax[0], mMinMax[1], destinationRelativeX); + for (int i = 0; i < mFlingAnimations.size(); i++) { + mFlingAnimations.get(i).start(); + } + } + + @Override + public void onClick(@NonNull FloatingTaskView v) { + if (v.isStashed()) { + final float centerX = mPositionRect.centerX() - (v.getWidth() / 2f); + final boolean viewOnLeftSide = v.getTranslationX() < centerX; + final float destinationRelativeX = viewOnLeftSide + ? mPositionRect.left + : mPositionRect.right - v.getWidth(); + final float minimumVelocityToReachEdge = + getMinimumVelocityToReachEdge(v, destinationRelativeX); + populateMinMax(v, viewOnLeftSide, /* stashed= */ true, mMinMax); + + cancelAnyAnimations(v); + + FlingAnimation flingAnimation = new FlingAnimation(v, + DynamicAnimation.TRANSLATION_X); + flingAnimation.setFriction(FLING_FRICTION) + .setStartVelocity(minimumVelocityToReachEdge) + .setMinValue(mMinMax[0]) + .setMaxValue(mMinMax[1]) + .addEndListener((animation, canceled, value, velocity) -> { + if (canceled) return; + mController.setLastPosition((int) v.getTranslationX(), + (int) v.getTranslationY()); + v.setStashed(false); + v.updateLocation(); + }); + mFlingAnimations = new ArrayList<>(); + mFlingAnimations.add(flingAnimation); + flingAnimation.start(); + } + } + + public void onUnstuckFromTarget(FloatingTaskView v, float velX, float velY, + boolean wasFlungOut) { + if (wasFlungOut) { + snapTaskViewToEdge(v, velX, /* shouldStash= */ false); + } else { + // TODO: use this for something / to spring the view to the touch location + mSpringToTouchOnNextMotionEvent = true; + } + } + + public void stashTaskView(FloatingTaskView v, boolean shouldStash) { + if (v.isStashed() == shouldStash) { + return; + } + final float centerX = mPositionRect.centerX() - (v.getWidth() / 2f); + final boolean viewOnLeftSide = v.getTranslationX() < centerX; + snapTaskViewToEdge(v, viewOnLeftSide ? -ESCAPE_VELOCITY : ESCAPE_VELOCITY, shouldStash); + } + + public boolean isStashedPosition(View v) { + return v.getTranslationX() < mPositionRect.left + || v.getTranslationX() + v.getWidth() > mPositionRect.right; + } + + // TODO: a lot of this is duplicated in onUp -- can it be unified? + private void snapTaskViewToEdge(FloatingTaskView v, float velX, boolean shouldStash) { + final boolean movingLeft = velX < ESCAPE_VELOCITY; + populateMinMax(v, movingLeft, shouldStash, mMinMax); + float destinationRelativeX = movingLeft + ? mMinMax[0] + : mMinMax[1]; + + // TODO: why is this necessary / when does this happen? + if (mMinMax[1] < v.getTranslationX()) { + mMinMax[1] = v.getTranslationX(); + } + if (v.getTranslationX() < mMinMax[0]) { + mMinMax[0] = v.getTranslationX(); + } + + // Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity + // so that it'll make it all the way to the side of the screen. + final float minimumVelocityToReachEdge = + getMinimumVelocityToReachEdge(v, destinationRelativeX); + final float startXVelocity = movingLeft + ? Math.min(minimumVelocityToReachEdge, velX) + : Math.max(minimumVelocityToReachEdge, velX); + + cancelAnyAnimations(v); + + mFlingAnimations = getAnimationForUpEvent(v, + shouldStash, startXVelocity, mMinMax[0], mMinMax[1], + destinationRelativeX); + for (int i = 0; i < mFlingAnimations.size(); i++) { + mFlingAnimations.get(i).start(); + } + } + + private void cancelAnyAnimations(FloatingTaskView v) { + if (mFlingAnimations != null) { + for (int i = 0; i < mFlingAnimations.size(); i++) { + if (mFlingAnimations.get(i).isRunning()) { + mFlingAnimations.get(i).cancel(); + } + } + } + if (mViewPropertyAnimation != null) { + mViewPropertyAnimation.cancel(); + mViewPropertyAnimation = null; + } + } + + private ArrayList<FlingAnimation> getAnimationForUpEvent(FloatingTaskView v, + boolean shouldStash, float startVelX, float minValue, float maxValue, + float destinationRelativeX) { + final float ty = v.getTranslationY(); + final ArrayList<FlingAnimation> animations = new ArrayList<>(); + if (ty != capY(v, ty)) { + // The view was being dismissed so the Y is out of bounds, need to animate that. + FlingAnimation yFlingAnimation = new FlingAnimation(v, + DynamicAnimation.TRANSLATION_Y); + yFlingAnimation.setFriction(FLING_FRICTION) + .setStartVelocity(startVelX) + .setMinValue(mPositionRect.top) + .setMaxValue(mPositionRect.bottom - mTaskViewSize.y); + animations.add(yFlingAnimation); + } + FlingAnimation flingAnimation = new FlingAnimation(v, DynamicAnimation.TRANSLATION_X); + flingAnimation.setFriction(FLING_FRICTION) + .setStartVelocity(startVelX) + .setMinValue(minValue) + .setMaxValue(maxValue) + .addEndListener((animation, canceled, value, velocity) -> { + if (canceled) return; + Runnable endAction = () -> { + v.setStashed(shouldStash); + v.updateLocation(); + if (!v.isStashed()) { + mController.setLastPosition((int) v.getTranslationX(), + (int) v.getTranslationY()); + } + }; + if (!shouldStash) { + final int xTranslation = (int) v.getTranslationX(); + if (xTranslation != destinationRelativeX) { + // TODO: this animation doesn't feel great, should figure out + // a better way to do this or remove the need for it all together. + mViewPropertyAnimation = v.animate() + .translationX(destinationRelativeX) + .setListener(getAnimationListener(endAction)); + mViewPropertyAnimation.start(); + } else { + endAction.run(); + } + } else { + endAction.run(); + } + }); + animations.add(flingAnimation); + return animations; + } + + private AnimatorListenerAdapter getAnimationListener(Runnable endAction) { + return new AnimatorListenerAdapter() { + boolean translationCanceled = false; + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + translationCanceled = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + if (!translationCanceled) { + endAction.run(); + } + } + }; + } + + private void populateMinMax(FloatingTaskView v, boolean onLeft, boolean shouldStash, + float[] out) { + if (shouldStash) { + out[0] = onLeft + ? mPositionRect.left - v.getWidth() + mOverhangWhenStashed + : mPositionRect.right - v.getWidth(); + out[1] = onLeft + ? mPositionRect.left + : mPositionRect.right - mOverhangWhenStashed; + } else { + out[0] = mPositionRect.left; + out[1] = mPositionRect.right - mTaskViewSize.x; + } + } + + private float getMinimumVelocityToReachEdge(FloatingTaskView v, + float destinationRelativeX) { + // Minimum velocity required for the view to make it to the targeted side of the screen, + // taking friction into account (4.2f is the number that friction scalars are multiplied + // by in DynamicAnimation.DragForce). This is an estimate and could be slightly off, the + // animation at the end will ensure that it reaches the destination X regardless. + return (destinationRelativeX - v.getTranslationX()) * (FLING_FRICTION * 4.2f); + } + + private float capX(FloatingTaskView v, float x, boolean isMoving) { + final int width = v.getWidth(); + if (v.isStashed() || isMoving) { + if (x < mPositionRect.left - v.getWidth() + mOverhangWhenStashed) { + return mPositionRect.left - v.getWidth() + mOverhangWhenStashed; + } + if (x > mPositionRect.right - mOverhangWhenStashed) { + return mPositionRect.right - mOverhangWhenStashed; + } + } else { + if (x < mPositionRect.left) { + return mPositionRect.left; + } + if (x > mPositionRect.right - width) { + return mPositionRect.right - width; + } + } + return x; + } + + private float capY(FloatingTaskView v, float y) { + final int height = v.getHeight(); + if (y < mPositionRect.top) { + return mPositionRect.top; + } + if (y > mPositionRect.bottom - height) { + return mPositionRect.bottom - height; + } + return y; + } + } +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskView.java new file mode 100644 index 0000000..581204a --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/floating/views/FloatingTaskView.java
@@ -0,0 +1,385 @@ +/* + * 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 com.android.wm.shell.floating.views; + +import static android.app.ActivityTaskManager.INVALID_TASK_ID; +import static android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK; +import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT; + +import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_FLOATING_APPS; + +import android.app.ActivityManager; +import android.app.ActivityOptions; +import android.app.ActivityTaskManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Outline; +import android.graphics.Rect; +import android.os.RemoteException; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; + +import com.android.internal.policy.ScreenDecorationsUtils; +import com.android.internal.protolog.common.ProtoLog; +import com.android.wm.shell.R; +import com.android.wm.shell.ShellTaskOrganizer; +import com.android.wm.shell.TaskView; +import com.android.wm.shell.TaskViewTransitions; +import com.android.wm.shell.bubbles.RelativeTouchListener; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.SyncTransactionQueue; +import com.android.wm.shell.common.annotations.ShellMainThread; +import com.android.wm.shell.floating.FloatingTasksController; + +/** + * A view that holds a floating task using {@link TaskView} along with additional UI to manage + * the task. + */ +public class FloatingTaskView extends FrameLayout { + + private static final String TAG = FloatingTaskView.class.getSimpleName(); + + private FloatingTasksController mController; + + private FloatingMenuView mMenuView; + private int mMenuHeight; + private TaskView mTaskView; + + private float mCornerRadius = 0f; + private int mBackgroundColor; + + private FloatingTasksController.Task mTask; + + private boolean mIsStashed; + + /** + * Creates a floating task view. + * + * @param context the context to use. + * @param controller the controller to notify about changes in the floating task (e.g. removal). + */ + public FloatingTaskView(Context context, FloatingTasksController controller) { + super(context); + mController = controller; + setElevation(getResources().getDimensionPixelSize(R.dimen.floating_task_elevation)); + mMenuHeight = context.getResources().getDimensionPixelSize(R.dimen.floating_task_menu_size); + mMenuView = new FloatingMenuView(context); + addView(mMenuView); + + applyThemeAttrs(); + + setClipToOutline(true); + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius); + } + }); + } + + // TODO: call this when theme/config changes + void applyThemeAttrs() { + boolean supportsRoundedCorners = ScreenDecorationsUtils.supportsRoundedCornersOnWindows( + mContext.getResources()); + final TypedArray ta = mContext.obtainStyledAttributes(new int[] { + android.R.attr.dialogCornerRadius, + android.R.attr.colorBackgroundFloating}); + mCornerRadius = supportsRoundedCorners ? ta.getDimensionPixelSize(0, 0) : 0; + mCornerRadius = mCornerRadius / 2f; + mBackgroundColor = ta.getColor(1, Color.WHITE); + + ta.recycle(); + + mMenuView.setCornerRadius(mCornerRadius); + mMenuHeight = getResources().getDimensionPixelSize( + R.dimen.floating_task_menu_size); + + if (mTaskView != null) { + mTaskView.setCornerRadius(mCornerRadius); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int height = MeasureSpec.getSize(heightMeasureSpec); + + // Add corner radius here so that the menu extends behind the rounded corners of TaskView. + int menuViewHeight = Math.min((int) (mMenuHeight + mCornerRadius), height); + measureChild(mMenuView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(menuViewHeight, + MeasureSpec.getMode(heightMeasureSpec))); + + if (mTaskView != null) { + int taskViewHeight = height - menuViewHeight; + measureChild(mTaskView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(taskViewHeight, + MeasureSpec.getMode(heightMeasureSpec))); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + // Drag handle above + final int dragHandleBottom = t + mMenuView.getMeasuredHeight(); + mMenuView.layout(l, t, r, dragHandleBottom); + if (mTaskView != null) { + // Subtract radius so that the menu extends behind the rounded corners of TaskView. + mTaskView.layout(l, (int) (dragHandleBottom - mCornerRadius), r, + dragHandleBottom + mTaskView.getMeasuredHeight()); + } + } + + /** + * Constructs the TaskView to display the task. Must be called for {@link #startTask} to work. + */ + public void createTaskView(Context context, ShellTaskOrganizer organizer, + TaskViewTransitions transitions, SyncTransactionQueue syncQueue) { + mTaskView = new TaskView(context, organizer, transitions, syncQueue); + addView(mTaskView); + mTaskView.setEnableSurfaceClipping(true); + mTaskView.setCornerRadius(mCornerRadius); + } + + /** + * Starts the provided task in the TaskView, if the TaskView exists. This should be called after + * {@link #createTaskView}. + */ + public void startTask(@ShellMainThread ShellExecutor executor, + FloatingTasksController.Task task) { + if (mTaskView == null) { + Log.e(TAG, "starting task before creating the view!"); + return; + } + mTask = task; + mTaskView.setListener(executor, mTaskViewListener); + } + + /** + * Sets the touch handler for the view. + * + * @param handler the touch handler for the view. + */ + public void setTouchHandler(FloatingTaskLayer.FloatingTaskTouchHandler handler) { + setOnTouchListener(new RelativeTouchListener() { + @Override + public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) { + handler.onDown(FloatingTaskView.this, ev, v.getTranslationX(), v.getTranslationY()); + return true; + } + + @Override + public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, + float viewInitialY, float dx, float dy) { + handler.onMove(FloatingTaskView.this, ev, dx, dy); + } + + @Override + public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX, + float viewInitialY, float dx, float dy, float velX, float velY) { + handler.onUp(FloatingTaskView.this, ev, dx, dy, velX, velY); + } + }); + setOnClickListener(view -> { + handler.onClick(FloatingTaskView.this); + }); + + mMenuView.addMenuItem(null, view -> { + if (mIsStashed) { + // If we're stashed all clicks un-stash. + handler.onClick(FloatingTaskView.this); + } + }); + } + + private void setContentVisibility(boolean visible) { + if (mTaskView == null) return; + mTaskView.setAlpha(visible ? 1f : 0f); + } + + /** + * Sets the alpha of both this view and the TaskView. + */ + public void setTaskViewAlpha(float alpha) { + if (mTaskView != null) { + mTaskView.setAlpha(alpha); + } + setAlpha(alpha); + } + + /** + * Call when the location or size of the view has changed to update TaskView. + */ + public void updateLocation() { + if (mTaskView == null) return; + mTaskView.onLocationChanged(); + } + + private void updateMenuColor() { + ActivityManager.RunningTaskInfo info = mTaskView.getTaskInfo(); + int color = info != null ? info.taskDescription.getBackgroundColor() : -1; + if (color != -1) { + mMenuView.setBackgroundColor(color); + } else { + mMenuView.setBackgroundColor(mBackgroundColor); + } + } + + /** + * Sets whether the view is stashed or not. + * + * Also updates the touchable area based on this. If the view is stashed we don't direct taps + * on the activity to the activity, instead a tap will un-stash the view. + */ + public void setStashed(boolean isStashed) { + if (mIsStashed != isStashed) { + mIsStashed = isStashed; + if (mTaskView == null) { + return; + } + updateObscuredTouchRect(); + } + } + + /** Whether the view is stashed at the edge of the screen or not. **/ + public boolean isStashed() { + return mIsStashed; + } + + private void updateObscuredTouchRect() { + if (mIsStashed) { + Rect tmpRect = new Rect(); + getBoundsOnScreen(tmpRect); + mTaskView.setObscuredTouchRect(tmpRect); + } else { + mTaskView.setObscuredTouchRect(null); + } + } + + /** + * Whether the task needs to be restarted, this can happen when {@link #cleanUpTaskView()} has + * been called on this view or if + * {@link #startTask(ShellExecutor, FloatingTasksController.Task)} was never called. + */ + public boolean needsTaskStarted() { + // If the task needs to be restarted then TaskView would have been cleaned up. + return mTaskView == null; + } + + /** Call this when the floating task activity is no longer in use. */ + public void cleanUpTaskView() { + if (mTask != null && mTask.taskId != INVALID_TASK_ID) { + try { + ActivityTaskManager.getService().removeTask(mTask.taskId); + } catch (RemoteException e) { + Log.e(TAG, e.getMessage()); + } + } + if (mTaskView != null) { + mTaskView.release(); + removeView(mTaskView); + mTaskView = null; + } + } + + // TODO: use task background colour / how to get the taskInfo ? + private static int getDragBarColor(ActivityManager.RunningTaskInfo taskInfo) { + final int taskBgColor = taskInfo.taskDescription.getStatusBarColor(); + return Color.valueOf(taskBgColor == -1 ? Color.WHITE : taskBgColor).toArgb(); + } + + private final TaskView.Listener mTaskViewListener = new TaskView.Listener() { + private boolean mInitialized = false; + private boolean mDestroyed = false; + + @Override + public void onInitialized() { + if (mDestroyed || mInitialized) { + return; + } + // Custom options so there is no activity transition animation + ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(), + /* enterResId= */ 0, /* exitResId= */ 0); + + Rect launchBounds = new Rect(); + mTaskView.getBoundsOnScreen(launchBounds); + + try { + options.setTaskAlwaysOnTop(true); + if (mTask.intent != null) { + Intent fillInIntent = new Intent(); + // Apply flags to make behaviour match documentLaunchMode=always. + fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT); + fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK); + + PendingIntent pi = PendingIntent.getActivity(mContext, 0, mTask.intent, + PendingIntent.FLAG_MUTABLE, + null); + mTaskView.startActivity(pi, fillInIntent, options, launchBounds); + } else { + ProtoLog.e(WM_SHELL_FLOATING_APPS, "Tried to start a task with null intent"); + } + } catch (RuntimeException e) { + ProtoLog.e(WM_SHELL_FLOATING_APPS, "Exception while starting task: %s", + e.getMessage()); + mController.removeTask(); + } + mInitialized = true; + } + + @Override + public void onReleased() { + mDestroyed = true; + } + + @Override + public void onTaskCreated(int taskId, ComponentName name) { + mTask.taskId = taskId; + updateMenuColor(); + setContentVisibility(true); + } + + @Override + public void onTaskVisibilityChanged(int taskId, boolean visible) { + setContentVisibility(visible); + } + + @Override + public void onTaskRemovalStarted(int taskId) { + // Must post because this is called from a binder thread. + post(() -> { + mController.removeTask(); + cleanUpTaskView(); + }); + } + + @Override + public void onBackPressedOnTaskRoot(int taskId) { + if (mTask.taskId == taskId && !mIsStashed) { + // TODO: is removing the window the desired behavior? + post(() -> mController.removeTask()); + } + } + }; +}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java b/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java index 3fef823..c52ed24 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java
@@ -48,6 +48,8 @@ Consts.TAG_WM_SHELL), WM_SHELL_DESKTOP_MODE(Consts.ENABLE_DEBUG, Consts.ENABLE_LOG_TO_PROTO_DEBUG, false, Consts.TAG_WM_SHELL), + WM_SHELL_FLOATING_APPS(Consts.ENABLE_DEBUG, Consts.ENABLE_LOG_TO_PROTO_DEBUG, false, + Consts.TAG_WM_SHELL), TEST_GROUP(true, true, false, "WindowManagerShellProtoLogTest"); private final boolean mEnabled;
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/floating/FloatingTasksControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/floating/FloatingTasksControllerTest.java new file mode 100644 index 0000000..f1b4311 --- /dev/null +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/floating/FloatingTasksControllerTest.java
@@ -0,0 +1,245 @@ +/* + * 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 com.android.wm.shell.floating; + +import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn; +import static com.android.wm.shell.floating.FloatingTasksController.SMALLEST_SCREEN_WIDTH_DP_TO_BE_TABLET; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Insets; +import android.graphics.Rect; +import android.os.RemoteException; +import android.os.SystemProperties; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.WindowMetrics; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.wm.shell.ShellTaskOrganizer; +import com.android.wm.shell.ShellTestCase; +import com.android.wm.shell.TaskViewTransitions; +import com.android.wm.shell.TestShellExecutor; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.SyncTransactionQueue; +import com.android.wm.shell.floating.views.FloatingTaskLayer; +import com.android.wm.shell.sysui.ShellCommandHandler; +import com.android.wm.shell.sysui.ShellController; +import com.android.wm.shell.sysui.ShellInit; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Tests for the floating tasks controller. + */ +@SmallTest +@RunWith(AndroidJUnit4.class) +public class FloatingTasksControllerTest extends ShellTestCase { + // Some behavior in the controller constructor is dependent on this so we can only + // validate if it's working for the real value for those things. + private static final boolean FLOATING_TASKS_ACTUALLY_ENABLED = + SystemProperties.getBoolean("persist.wm.debug.floating_tasks", false); + + @Mock private ShellInit mShellInit; + @Mock private ShellController mShellController; + @Mock private WindowManager mWindowManager; + @Mock private ShellTaskOrganizer mTaskOrganizer; + @Captor private ArgumentCaptor<FloatingTaskLayer> mFloatingTaskLayerCaptor; + + private FloatingTasksController mController; + + @Before + public void setUp() throws RemoteException { + MockitoAnnotations.initMocks(this); + + WindowMetrics windowMetrics = mock(WindowMetrics.class); + WindowInsets windowInsets = mock(WindowInsets.class); + Insets insets = Insets.of(0, 0, 0, 0); + when(mWindowManager.getCurrentWindowMetrics()).thenReturn(windowMetrics); + when(windowMetrics.getWindowInsets()).thenReturn(windowInsets); + when(windowMetrics.getBounds()).thenReturn(new Rect(0, 0, 1000, 1000)); + when(windowInsets.getInsetsIgnoringVisibility(anyInt())).thenReturn(insets); + + // For the purposes of this test, just run everything synchronously + ShellExecutor shellExecutor = new TestShellExecutor(); + when(mTaskOrganizer.getExecutor()).thenReturn(shellExecutor); + } + + @After + public void tearDown() { + if (mController != null) { + mController.removeTask(); + mController = null; + } + } + + private void setUpTabletConfig() { + Configuration config = mock(Configuration.class); + config.smallestScreenWidthDp = SMALLEST_SCREEN_WIDTH_DP_TO_BE_TABLET; + mController.setConfig(config); + } + + private void setUpPhoneConfig() { + Configuration config = mock(Configuration.class); + config.smallestScreenWidthDp = SMALLEST_SCREEN_WIDTH_DP_TO_BE_TABLET - 1; + mController.setConfig(config); + } + + private void createController() { + mController = new FloatingTasksController(mContext, + mShellInit, + mShellController, + mock(ShellCommandHandler.class), + mWindowManager, + mTaskOrganizer, + mock(TaskViewTransitions.class), + mock(ShellExecutor.class), + mock(ShellExecutor.class), + mock(SyncTransactionQueue.class)); + spyOn(mController); + } + + // + // Shell specific + // + @Test + public void instantiateController_addInitCallback() { + if (FLOATING_TASKS_ACTUALLY_ENABLED) { + createController(); + setUpTabletConfig(); + + verify(mShellInit, times(1)).addInitCallback(any(), any()); + } + } + + @Test + public void instantiateController_doesntAddInitCallback() { + if (!FLOATING_TASKS_ACTUALLY_ENABLED) { + createController(); + + verify(mShellInit, never()).addInitCallback(any(), any()); + } + } + + @Test + public void onInit_registerConfigChangeListener() { + if (FLOATING_TASKS_ACTUALLY_ENABLED) { + createController(); + setUpTabletConfig(); + mController.onInit(); + + verify(mShellController, times(1)).addConfigurationChangeListener(any()); + } + } + + // + // Tests for floating layer, which is only available for tablets. + // + + @Test + public void testIsFloatingLayerAvailable_true() { + createController(); + setUpTabletConfig(); + assertThat(mController.isFloatingLayerAvailable()).isTrue(); + } + + @Test + public void testIsFloatingLayerAvailable_false() { + createController(); + setUpPhoneConfig(); + assertThat(mController.isFloatingLayerAvailable()).isFalse(); + } + + // + // Tests for floating tasks being enabled, guarded by sysprop flag. + // + + @Test + public void testIsFloatingTasksEnabled_true() { + createController(); + mController.setFloatingTasksEnabled(true); + setUpTabletConfig(); + assertThat(mController.isFloatingTasksEnabled()).isTrue(); + } + + @Test + public void testIsFloatingTasksEnabled_false() { + createController(); + mController.setFloatingTasksEnabled(false); + setUpTabletConfig(); + assertThat(mController.isFloatingTasksEnabled()).isFalse(); + } + + // + // Tests for behavior depending on flags + // + + @Test + public void testShowTaskIntent_enabled() { + createController(); + mController.setFloatingTasksEnabled(true); + setUpTabletConfig(); + + mController.showTask(mock(Intent.class)); + verify(mWindowManager).addView(mFloatingTaskLayerCaptor.capture(), any()); + assertThat(mFloatingTaskLayerCaptor.getValue().getTaskViewCount()).isEqualTo(1); + } + + @Test + public void testShowTaskIntent_notEnabled() { + createController(); + mController.setFloatingTasksEnabled(false); + setUpTabletConfig(); + + mController.showTask(mock(Intent.class)); + verify(mWindowManager, never()).addView(any(), any()); + } + + @Test + public void testRemoveTask() { + createController(); + mController.setFloatingTasksEnabled(true); + setUpTabletConfig(); + + mController.showTask(mock(Intent.class)); + verify(mWindowManager).addView(mFloatingTaskLayerCaptor.capture(), any()); + assertThat(mFloatingTaskLayerCaptor.getValue().getTaskViewCount()).isEqualTo(1); + + mController.removeTask(); + verify(mWindowManager).removeView(mFloatingTaskLayerCaptor.capture()); + assertThat(mFloatingTaskLayerCaptor.getValue().getTaskViewCount()).isEqualTo(0); + } +}
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/system/QuickStepContract.java b/packages/SystemUI/shared/src/com/android/systemui/shared/system/QuickStepContract.java index 97e0242..6d12485 100644 --- a/packages/SystemUI/shared/src/com/android/systemui/shared/system/QuickStepContract.java +++ b/packages/SystemUI/shared/src/com/android/systemui/shared/system/QuickStepContract.java
@@ -47,6 +47,8 @@ public static final String KEY_EXTRA_SHELL_PIP = "extra_shell_pip"; // See ISplitScreen.aidl public static final String KEY_EXTRA_SHELL_SPLIT_SCREEN = "extra_shell_split_screen"; + // See IFloatingTasks.aidl + public static final String KEY_EXTRA_SHELL_FLOATING_TASKS = "extra_shell_floating_tasks"; // See IOneHanded.aidl public static final String KEY_EXTRA_SHELL_ONE_HANDED = "extra_shell_one_handed"; // See IShellTransitions.aidl
diff --git a/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java b/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java index 5f586c9..50c38e5 100644 --- a/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java +++ b/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java
@@ -96,7 +96,8 @@ .setStartingSurface(mWMComponent.getStartingSurface()) .setDisplayAreaHelper(mWMComponent.getDisplayAreaHelper()) .setRecentTasks(mWMComponent.getRecentTasks()) - .setBackAnimation(mWMComponent.getBackAnimation()); + .setBackAnimation(mWMComponent.getBackAnimation()) + .setFloatingTasks(mWMComponent.getFloatingTasks()); // Only initialize when not starting from tests since this currently initializes some // components that shouldn't be run in the test environment @@ -115,7 +116,8 @@ .setDisplayAreaHelper(Optional.ofNullable(null)) .setStartingSurface(Optional.ofNullable(null)) .setRecentTasks(Optional.ofNullable(null)) - .setBackAnimation(Optional.ofNullable(null)); + .setBackAnimation(Optional.ofNullable(null)) + .setFloatingTasks(Optional.ofNullable(null)); } mSysUIComponent = builder.build(); if (initializeComponents) {
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java b/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java index 029cabb..7e30431 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java
@@ -41,6 +41,7 @@ import com.android.wm.shell.back.BackAnimation; import com.android.wm.shell.bubbles.Bubbles; import com.android.wm.shell.displayareahelper.DisplayAreaHelper; +import com.android.wm.shell.floating.FloatingTasks; import com.android.wm.shell.onehanded.OneHanded; import com.android.wm.shell.pip.Pip; import com.android.wm.shell.recents.RecentTasks; @@ -109,6 +110,9 @@ @BindsInstance Builder setBackAnimation(Optional<BackAnimation> b); + @BindsInstance + Builder setFloatingTasks(Optional<FloatingTasks> f); + SysUIComponent build(); }
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/WMComponent.java b/packages/SystemUI/src/com/android/systemui/dagger/WMComponent.java index b6923a8..dd11549 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/WMComponent.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/WMComponent.java
@@ -23,8 +23,6 @@ import com.android.systemui.SystemUIInitializerFactory; import com.android.systemui.tv.TvWMComponent; -import com.android.wm.shell.sysui.ShellCommandHandler; -import com.android.wm.shell.sysui.ShellInit; import com.android.wm.shell.TaskViewFactory; import com.android.wm.shell.back.BackAnimation; import com.android.wm.shell.bubbles.Bubbles; @@ -33,6 +31,7 @@ import com.android.wm.shell.dagger.WMShellModule; import com.android.wm.shell.dagger.WMSingleton; import com.android.wm.shell.displayareahelper.DisplayAreaHelper; +import com.android.wm.shell.floating.FloatingTasks; import com.android.wm.shell.onehanded.OneHanded; import com.android.wm.shell.pip.Pip; import com.android.wm.shell.recents.RecentTasks; @@ -110,4 +109,7 @@ @WMSingleton Optional<BackAnimation> getBackAnimation(); + + @WMSingleton + Optional<FloatingTasks> getFloatingTasks(); }
diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java index cd6c57a..e712ea4 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java
@@ -234,6 +234,10 @@ public static final SysPropBooleanFlag WM_CAPTION_ON_SHELL = new SysPropBooleanFlag(1105, "persist.wm.debug.caption_on_shell", false); + @Keep + public static final SysPropBooleanFlag FLOATING_TASKS_ENABLED = + new SysPropBooleanFlag(1106, "persist.wm.debug.floating_tasks", false); + // 1200 - predictive back @Keep public static final SysPropBooleanFlag WM_ENABLE_PREDICTIVE_BACK = new SysPropBooleanFlag(
diff --git a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java index 3788ad9..95edb35 100644 --- a/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java +++ b/packages/SystemUI/src/com/android/systemui/recents/OverviewProxyService.java
@@ -27,6 +27,7 @@ import static com.android.internal.accessibility.common.ShortcutConstants.CHOOSER_PACKAGE_NAME; import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_RECENT_TASKS; import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_SHELL_BACK_ANIMATION; +import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_SHELL_FLOATING_TASKS; import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_SHELL_ONE_HANDED; import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_SHELL_PIP; import static com.android.systemui.shared.system.QuickStepContract.KEY_EXTRA_SHELL_SHELL_TRANSITIONS; @@ -109,6 +110,7 @@ import com.android.systemui.statusbar.phone.StatusBarWindowCallback; import com.android.systemui.statusbar.policy.CallbackController; import com.android.wm.shell.back.BackAnimation; +import com.android.wm.shell.floating.FloatingTasks; import com.android.wm.shell.onehanded.OneHanded; import com.android.wm.shell.pip.Pip; import com.android.wm.shell.pip.PipAnimationController; @@ -150,6 +152,7 @@ private final Optional<Pip> mPipOptional; private final Lazy<Optional<CentralSurfaces>> mCentralSurfacesOptionalLazy; private final Optional<SplitScreen> mSplitScreenOptional; + private final Optional<FloatingTasks> mFloatingTasksOptional; private SysUiState mSysUiState; private final Handler mHandler; private final Lazy<NavigationBarController> mNavBarControllerLazy; @@ -382,7 +385,6 @@ mCentralSurfacesOptionalLazy.get().ifPresent(CentralSurfaces::togglePanel)); } - private boolean verifyCaller(String reason) { final int callerId = Binder.getCallingUserHandle().getIdentifier(); if (callerId != mCurrentBoundedUserId) { @@ -466,6 +468,9 @@ mSplitScreenOptional.ifPresent((splitscreen) -> params.putBinder( KEY_EXTRA_SHELL_SPLIT_SCREEN, splitscreen.createExternalInterface().asBinder())); + mFloatingTasksOptional.ifPresent(floatingTasks -> params.putBinder( + KEY_EXTRA_SHELL_FLOATING_TASKS, + floatingTasks.createExternalInterface().asBinder())); mOneHandedOptional.ifPresent((onehanded) -> params.putBinder( KEY_EXTRA_SHELL_ONE_HANDED, onehanded.createExternalInterface().asBinder())); @@ -563,6 +568,7 @@ NotificationShadeWindowController statusBarWinController, SysUiState sysUiState, Optional<Pip> pipOptional, Optional<SplitScreen> splitScreenOptional, + Optional<FloatingTasks> floatingTasksOptional, Optional<OneHanded> oneHandedOptional, Optional<RecentTasks> recentTasks, Optional<BackAnimation> backAnimation, @@ -631,6 +637,7 @@ mCommandQueue = commandQueue; mSplitScreenOptional = splitScreenOptional; + mFloatingTasksOptional = floatingTasksOptional; // Listen for user setup startTracking();
diff --git a/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java b/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java index 3961a8b..3472cb1 100644 --- a/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java +++ b/packages/SystemUI/src/com/android/systemui/wmshell/WMShell.java
@@ -55,6 +55,7 @@ import com.android.systemui.statusbar.policy.KeyguardStateController; import com.android.systemui.tracing.ProtoTracer; import com.android.systemui.tracing.nano.SystemUiTraceProto; +import com.android.wm.shell.floating.FloatingTasks; import com.android.wm.shell.nano.WmShellTraceProto; import com.android.wm.shell.onehanded.OneHanded; import com.android.wm.shell.onehanded.OneHandedEventCallback; @@ -106,6 +107,7 @@ private final Optional<Pip> mPipOptional; private final Optional<SplitScreen> mSplitScreenOptional; private final Optional<OneHanded> mOneHandedOptional; + private final Optional<FloatingTasks> mFloatingTasksOptional; private final CommandQueue mCommandQueue; private final ConfigurationController mConfigurationController; @@ -166,6 +168,7 @@ Optional<Pip> pipOptional, Optional<SplitScreen> splitScreenOptional, Optional<OneHanded> oneHandedOptional, + Optional<FloatingTasks> floatingTasksOptional, CommandQueue commandQueue, ConfigurationController configurationController, KeyguardStateController keyguardStateController, @@ -190,6 +193,7 @@ mWakefulnessLifecycle = wakefulnessLifecycle; mProtoTracer = protoTracer; mUserTracker = userTracker; + mFloatingTasksOptional = floatingTasksOptional; mSysUiMainExecutor = sysUiMainExecutor; }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/wmshell/WMShellTest.java b/packages/SystemUI/tests/src/com/android/systemui/wmshell/WMShellTest.java index da33fa6..cebe946 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/wmshell/WMShellTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/wmshell/WMShellTest.java
@@ -34,6 +34,7 @@ import com.android.systemui.statusbar.policy.KeyguardStateController; import com.android.systemui.tracing.ProtoTracer; import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.floating.FloatingTasks; import com.android.wm.shell.onehanded.OneHanded; import com.android.wm.shell.onehanded.OneHandedEventCallback; import com.android.wm.shell.onehanded.OneHandedTransitionCallback; @@ -74,16 +75,16 @@ @Mock ProtoTracer mProtoTracer; @Mock UserTracker mUserTracker; @Mock ShellExecutor mSysUiMainExecutor; + @Mock FloatingTasks mFloatingTasks; @Before public void setUp() { MockitoAnnotations.initMocks(this); - mWMShell = new WMShell(mContext, mShellInterface, Optional.of(mPip), - Optional.of(mSplitScreen), Optional.of(mOneHanded), mCommandQueue, - mConfigurationController, mKeyguardStateController, mKeyguardUpdateMonitor, - mScreenLifecycle, mSysUiState, mProtoTracer, mWakefulnessLifecycle, - mUserTracker, mSysUiMainExecutor); + Optional.of(mSplitScreen), Optional.of(mOneHanded), Optional.of(mFloatingTasks), + mCommandQueue, mConfigurationController, mKeyguardStateController, + mKeyguardUpdateMonitor, mScreenLifecycle, mSysUiState, mProtoTracer, + mWakefulnessLifecycle, mUserTracker, mSysUiMainExecutor); } @Test