diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml index 5696b8dfa8e38..0bc70857a1137 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. --> 48dp + + + 32dp + + + 24dp + + + 5dp + + + 32dp + + + 8dp + + + 8dp + + + 150dp + + + 120dp 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 976fba52b9e22..e0c782d1675bb 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 @@ public class DismissCircleView extends FrameLayout { @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 7c3c14e2210c3..c9d523c779ea9 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.content.pm.PackageManager; 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.desktopmode.DesktopModeTaskRepository; 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; @@ -571,6 +574,45 @@ public abstract class WMShellBaseModule { return Optional.empty(); } + // + // Floating tasks + // + + @WMSingleton + @Provides + static Optional provideFloatingTasks( + Optional floatingTaskController) { + return floatingTaskController.map((controller) -> controller.asFloatingTasks()); + } + + @WMSingleton + @Provides + static Optional 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 0000000000000..83a1734dc71ab --- /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 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 getMagnetizedView(View v) { + if (mMagnetizedObject != null + && Objects.equals(mMagnetizedObject.getUnderlyingObject(), v)) { + // Same view being dragged, we can reuse the magnetic object. + return mMagnetizedObject; + } + MagnetizedObject magnetizedView = new MagnetizedObject( + 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 0000000000000..935666026bf42 --- /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 0000000000000..c79b9b8cc443a --- /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, + 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 0000000000000..f79ca1039865d --- /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 0000000000000..c922109751ba6 --- /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 0000000000000..16dab2415bf2c --- /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 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 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 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 getAnimationForUpEvent(FloatingTaskView v, + boolean shouldStash, float startVelX, float minValue, float maxValue, + float destinationRelativeX) { + final float ty = v.getTranslationY(); + final ArrayList 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 0000000000000..581204a82ec7e --- /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 3fef823527285..c52ed249c2ca7 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 @@ public enum ShellProtoLogGroup implements IProtoLogGroup { 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 0000000000000..f1b4311e5aa56 --- /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 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 97e0242387789..6d12485fd6b5f 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 class QuickStepContract { 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 5f586c927ef74..50c38e54b4d0e 100644 --- a/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java +++ b/packages/SystemUI/src/com/android/systemui/SystemUIInitializer.java @@ -96,7 +96,8 @@ public abstract class SystemUIInitializer { .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 @@ public abstract class SystemUIInitializer { .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 029cabb0bc0bb..7e30431a30c83 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.TaskViewFactory; 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 @@ public interface SysUIComponent { @BindsInstance Builder setBackAnimation(Optional b); + @BindsInstance + Builder setFloatingTasks(Optional 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 b6923a867507c..dd115490abfa3 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 androidx.annotation.Nullable; 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.TvWMShellModule; 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 @@ public interface WMComponent { @WMSingleton Optional getBackAnimation(); + + @WMSingleton + Optional getFloatingTasks(); } diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java index cd6c57aea76b7..e712ea413a7f8 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 class Flags { 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 3788ad98092f6..95edb359c53cf 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 android.view.WindowManagerPolicyConstants.NAV_BAR_MODE_3BUTTON; 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.CentralSurfaces; 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 @@ public class OverviewProxyService extends CurrentUserTracker implements private final Optional mPipOptional; private final Lazy> mCentralSurfacesOptionalLazy; private final Optional mSplitScreenOptional; + private final Optional mFloatingTasksOptional; private SysUiState mSysUiState; private final Handler mHandler; private final Lazy mNavBarControllerLazy; @@ -382,7 +385,6 @@ public class OverviewProxyService extends CurrentUserTracker implements mCentralSurfacesOptionalLazy.get().ifPresent(CentralSurfaces::togglePanel)); } - private boolean verifyCaller(String reason) { final int callerId = Binder.getCallingUserHandle().getIdentifier(); if (callerId != mCurrentBoundedUserId) { @@ -466,6 +468,9 @@ public class OverviewProxyService extends CurrentUserTracker implements 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 @@ public class OverviewProxyService extends CurrentUserTracker implements NotificationShadeWindowController statusBarWinController, SysUiState sysUiState, Optional pipOptional, Optional splitScreenOptional, + Optional floatingTasksOptional, Optional oneHandedOptional, Optional recentTasks, Optional backAnimation, @@ -631,6 +637,7 @@ public class OverviewProxyService extends CurrentUserTracker implements 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 3961a8bcc9805..3472cb1c2a7db 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.ConfigurationController; 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 @@ public final class WMShell extends CoreStartable private final Optional mPipOptional; private final Optional mSplitScreenOptional; private final Optional mOneHandedOptional; + private final Optional mFloatingTasksOptional; private final CommandQueue mCommandQueue; private final ConfigurationController mConfigurationController; @@ -166,6 +168,7 @@ public final class WMShell extends CoreStartable Optional pipOptional, Optional splitScreenOptional, Optional oneHandedOptional, + Optional floatingTasksOptional, CommandQueue commandQueue, ConfigurationController configurationController, KeyguardStateController keyguardStateController, @@ -190,6 +193,7 @@ public final class WMShell extends CoreStartable 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 da33fa62a9ab3..cebe946a459c6 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.ConfigurationController; 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 @@ public class WMShellTest extends SysuiTestCase { @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