From dfc16e6493b66384b9b9f111753ff58384eff0cd Mon Sep 17 00:00:00 2001 From: Ikram Gabiyev Date: Thu, 4 Apr 2024 13:36:45 -0700 Subject: Move altered input handlers to pip2 [2/N] Move PipTouchHandler and PipMotionHelper into the pip2 package. Temporarily comment out PipTaskOrganizer usages in both, to be replaced by ...pip2.phone.PipScheduler The plan is to have these classes inject PipScheduler and not interact with the PipTransitionController directly (recall that PipTransition caches its instance in PipScheduler) Bug: 332770512 Test: N/A until PipTouchHandler is connected to PipScheduler Change-Id: I27baa0196850e8ff8c1fc62da95b4f1eb1b64208 --- .../android/wm/shell/dagger/pip/Pip2Module.java | 37 + .../shell/pip2/phone/PipDismissTargetHandler.java | 311 ++++++ .../wm/shell/pip2/phone/PipMotionHelper.java | 719 +++++++++++++ .../wm/shell/pip2/phone/PipTouchHandler.java | 1081 ++++++++++++++++++++ 4 files changed, 2148 insertions(+) create mode 100644 libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipDismissTargetHandler.java create mode 100644 libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipMotionHelper.java create mode 100644 libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java (limited to 'libs') diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/pip/Pip2Module.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/pip/Pip2Module.java index 458ea05e620d..63d628d23d3a 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/pip/Pip2Module.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/pip/Pip2Module.java @@ -23,6 +23,7 @@ import android.os.Handler; import com.android.wm.shell.ShellTaskOrganizer; import com.android.wm.shell.common.DisplayController; import com.android.wm.shell.common.DisplayInsetsController; +import com.android.wm.shell.common.FloatingContentCoordinator; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SystemWindows; import com.android.wm.shell.common.annotations.ShellMainThread; @@ -30,13 +31,18 @@ import com.android.wm.shell.common.pip.PipBoundsAlgorithm; import com.android.wm.shell.common.pip.PipBoundsState; import com.android.wm.shell.common.pip.PipDisplayLayoutState; import com.android.wm.shell.common.pip.PipMediaController; +import com.android.wm.shell.common.pip.PipPerfHintController; +import com.android.wm.shell.common.pip.PipSnapAlgorithm; import com.android.wm.shell.common.pip.PipUiEventLogger; import com.android.wm.shell.common.pip.PipUtils; +import com.android.wm.shell.common.pip.SizeSpecSource; import com.android.wm.shell.dagger.WMShellBaseModule; import com.android.wm.shell.dagger.WMSingleton; import com.android.wm.shell.pip2.phone.PhonePipMenuController; import com.android.wm.shell.pip2.phone.PipController; +import com.android.wm.shell.pip2.phone.PipMotionHelper; import com.android.wm.shell.pip2.phone.PipScheduler; +import com.android.wm.shell.pip2.phone.PipTouchHandler; import com.android.wm.shell.pip2.phone.PipTransition; import com.android.wm.shell.sysui.ShellController; import com.android.wm.shell.sysui.ShellInit; @@ -62,6 +68,7 @@ public abstract class Pip2Module { PipBoundsState pipBoundsState, PipBoundsAlgorithm pipBoundsAlgorithm, Optional pipController, + PipTouchHandler pipTouchHandler, @NonNull PipScheduler pipScheduler) { return new PipTransition(context, shellInit, shellTaskOrganizer, transitions, pipBoundsState, null, pipBoundsAlgorithm, pipScheduler); @@ -108,4 +115,34 @@ public abstract class Pip2Module { return new PhonePipMenuController(context, pipBoundsState, pipMediaController, systemWindows, pipUiEventLogger, mainExecutor, mainHandler); } + + + @WMSingleton + @Provides + static PipTouchHandler providePipTouchHandler(Context context, + ShellInit shellInit, + PhonePipMenuController menuPhoneController, + PipBoundsAlgorithm pipBoundsAlgorithm, + @NonNull PipBoundsState pipBoundsState, + @NonNull SizeSpecSource sizeSpecSource, + PipMotionHelper pipMotionHelper, + FloatingContentCoordinator floatingContentCoordinator, + PipUiEventLogger pipUiEventLogger, + @ShellMainThread ShellExecutor mainExecutor, + Optional pipPerfHintControllerOptional) { + return new PipTouchHandler(context, shellInit, menuPhoneController, pipBoundsAlgorithm, + pipBoundsState, sizeSpecSource, pipMotionHelper, floatingContentCoordinator, + pipUiEventLogger, mainExecutor, pipPerfHintControllerOptional); + } + + @WMSingleton + @Provides + static PipMotionHelper providePipMotionHelper(Context context, + PipBoundsState pipBoundsState, PhonePipMenuController menuController, + PipSnapAlgorithm pipSnapAlgorithm, + FloatingContentCoordinator floatingContentCoordinator, + Optional pipPerfHintControllerOptional) { + return new PipMotionHelper(context, pipBoundsState, menuController, pipSnapAlgorithm, + floatingContentCoordinator, pipPerfHintControllerOptional); + } } diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipDismissTargetHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipDismissTargetHandler.java new file mode 100644 index 000000000000..e7e797096c0e --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipDismissTargetHandler.java @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.pip2.phone; + +import static android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.view.MotionEvent; +import android.view.SurfaceControl; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.WindowInsets; +import android.view.WindowManager; + +import androidx.annotation.NonNull; + +import com.android.wm.shell.R; +import com.android.wm.shell.bubbles.DismissViewUtils; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.bubbles.DismissCircleView; +import com.android.wm.shell.common.bubbles.DismissView; +import com.android.wm.shell.common.magnetictarget.MagnetizedObject; +import com.android.wm.shell.common.pip.PipUiEventLogger; + +import kotlin.Unit; + +/** + * Handler of all Magnetized Object related code for PiP. + */ +public class PipDismissTargetHandler implements ViewTreeObserver.OnPreDrawListener { + + /* The multiplier to apply scale the target size by when applying the magnetic field radius */ + private static final float MAGNETIC_FIELD_RADIUS_MULTIPLIER = 1.25f; + + /** + * MagnetizedObject wrapper for PIP. This allows the magnetic target library to locate and move + * PIP. + */ + private MagnetizedObject mMagnetizedPip; + + /** + * Container for the dismiss circle, so that it can be animated within the container via + * translation rather than within the WindowManager via slow layout animations. + */ + private DismissView mTargetViewContainer; + + /** Circle view used to render the dismiss target. */ + private DismissCircleView mTargetView; + + /** + * MagneticTarget instance wrapping the target view and allowing us to set its magnetic radius. + */ + private MagnetizedObject.MagneticTarget mMagneticTarget; + + // Allow dragging the PIP to a location to close it + private boolean mEnableDismissDragToEdge; + + private int mTargetSize; + private int mDismissAreaHeight; + private float mMagneticFieldRadiusPercent = 1f; + private WindowInsets mWindowInsets; + + private SurfaceControl mTaskLeash; + private boolean mHasDismissTargetSurface; + + private final Context mContext; + private final PipMotionHelper mMotionHelper; + private final PipUiEventLogger mPipUiEventLogger; + private final WindowManager mWindowManager; + private final ShellExecutor mMainExecutor; + + public PipDismissTargetHandler(Context context, PipUiEventLogger pipUiEventLogger, + PipMotionHelper motionHelper, ShellExecutor mainExecutor) { + mContext = context; + mPipUiEventLogger = pipUiEventLogger; + mMotionHelper = motionHelper; + mMainExecutor = mainExecutor; + mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); + } + + void init() { + Resources res = mContext.getResources(); + mEnableDismissDragToEdge = res.getBoolean(R.bool.config_pipEnableDismissDragToEdge); + mDismissAreaHeight = res.getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height); + + if (mTargetViewContainer != null) { + // init can be called multiple times, remove the old one from view hierarchy first. + cleanUpDismissTarget(); + } + + mTargetViewContainer = new DismissView(mContext); + DismissViewUtils.setup(mTargetViewContainer); + mTargetView = mTargetViewContainer.getCircle(); + mTargetViewContainer.setOnApplyWindowInsetsListener((view, windowInsets) -> { + if (!windowInsets.equals(mWindowInsets)) { + mWindowInsets = windowInsets; + updateMagneticTargetSize(); + } + return windowInsets; + }); + + mMagnetizedPip = mMotionHelper.getMagnetizedPip(); + mMagnetizedPip.clearAllTargets(); + mMagneticTarget = mMagnetizedPip.addTarget(mTargetView, 0); + updateMagneticTargetSize(); + + mMagnetizedPip.setAnimateStuckToTarget( + (target, velX, velY, flung, after) -> { + if (mEnableDismissDragToEdge) { + mMotionHelper.animateIntoDismissTarget(target, velX, velY, flung, after); + } + return Unit.INSTANCE; + }); + mMagnetizedPip.setMagnetListener(new MagnetizedObject.MagnetListener() { + @Override + public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target, + @NonNull MagnetizedObject draggedObject) { + // Show the dismiss target, in case the initial touch event occurred within + // the magnetic field radius. + if (mEnableDismissDragToEdge) { + showDismissTargetMaybe(); + } + } + + @Override + public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, + @NonNull MagnetizedObject draggedObject, + float velX, float velY, boolean wasFlungOut) { + if (wasFlungOut) { + mMotionHelper.flingToSnapTarget(velX, velY, null /* endAction */); + hideDismissTargetMaybe(); + } else { + mMotionHelper.setSpringingToTouch(true); + } + } + + @Override + public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target, + @NonNull MagnetizedObject draggedObject) { + if (mEnableDismissDragToEdge) { + mMainExecutor.executeDelayed(() -> { + mMotionHelper.notifyDismissalPending(); + mMotionHelper.animateDismiss(); + hideDismissTargetMaybe(); + + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_DRAG_TO_REMOVE); + }, 0); + } + } + }); + + } + + @Override + public boolean onPreDraw() { + mTargetViewContainer.getViewTreeObserver().removeOnPreDrawListener(this); + mHasDismissTargetSurface = true; + updateDismissTargetLayer(); + return true; + } + + /** + * Potentially start consuming future motion events if PiP is currently near the magnetized + * object. + */ + public boolean maybeConsumeMotionEvent(MotionEvent ev) { + return mMagnetizedPip.maybeConsumeMotionEvent(ev); + } + + /** + * Update the magnet size. + */ + public void updateMagneticTargetSize() { + if (mTargetView == null) { + return; + } + if (mTargetViewContainer != null) { + mTargetViewContainer.updateResources(); + } + + final Resources res = mContext.getResources(); + mTargetSize = res.getDimensionPixelSize(R.dimen.dismiss_circle_size); + mDismissAreaHeight = res.getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height); + + // Set the magnetic field radius equal to the target size from the center of the target + setMagneticFieldRadiusPercent(mMagneticFieldRadiusPercent); + } + + /** + * Increase or decrease the field radius of the magnet object, e.g. with larger percent, + * PiP will magnetize to the field sooner. + */ + public void setMagneticFieldRadiusPercent(float percent) { + mMagneticFieldRadiusPercent = percent; + mMagneticTarget.setMagneticFieldRadiusPx((int) (mMagneticFieldRadiusPercent * mTargetSize + * MAGNETIC_FIELD_RADIUS_MULTIPLIER)); + } + + public void setTaskLeash(SurfaceControl taskLeash) { + mTaskLeash = taskLeash; + } + + private void updateDismissTargetLayer() { + if (!mHasDismissTargetSurface || mTaskLeash == null) { + // No dismiss target surface, can just return + return; + } + + final SurfaceControl targetViewLeash = + mTargetViewContainer.getViewRootImpl().getSurfaceControl(); + if (!targetViewLeash.isValid()) { + // The surface of mTargetViewContainer is somehow not ready, bail early + return; + } + + // Put the dismiss target behind the task + SurfaceControl.Transaction t = new SurfaceControl.Transaction(); + t.setRelativeLayer(targetViewLeash, mTaskLeash, -1); + t.apply(); + } + + /** Adds the magnetic target view to the WindowManager so it's ready to be animated in. */ + public void createOrUpdateDismissTarget() { + if (mTargetViewContainer.getParent() == null) { + mTargetViewContainer.cancelAnimators(); + + mTargetViewContainer.setVisibility(View.INVISIBLE); + mTargetViewContainer.getViewTreeObserver().removeOnPreDrawListener(this); + mHasDismissTargetSurface = false; + + mWindowManager.addView(mTargetViewContainer, getDismissTargetLayoutParams()); + } else { + mWindowManager.updateViewLayout(mTargetViewContainer, getDismissTargetLayoutParams()); + } + } + + /** Returns layout params for the dismiss target, using the latest display metrics. */ + private WindowManager.LayoutParams getDismissTargetLayoutParams() { + final Point windowSize = new Point(); + mWindowManager.getDefaultDisplay().getRealSize(windowSize); + int height = Math.min(windowSize.y, mDismissAreaHeight); + final WindowManager.LayoutParams lp = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + height, + 0, windowSize.y - height, + WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT); + + lp.setTitle("pip-dismiss-overlay"); + lp.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS; + lp.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + lp.setFitInsetsTypes(0 /* types */); + + return lp; + } + + /** Makes the dismiss target visible and animates it in, if it isn't already visible. */ + public void showDismissTargetMaybe() { + if (!mEnableDismissDragToEdge) { + return; + } + + createOrUpdateDismissTarget(); + + if (mTargetViewContainer.getVisibility() != View.VISIBLE) { + mTargetViewContainer.getViewTreeObserver().addOnPreDrawListener(this); + } + // always invoke show, since the target might still be VISIBLE while playing hide animation, + // so we want to ensure it will show back again + mTargetViewContainer.show(); + } + + /** Animates the magnetic dismiss target out and then sets it to GONE. */ + public void hideDismissTargetMaybe() { + if (!mEnableDismissDragToEdge) { + return; + } + mTargetViewContainer.hide(); + } + + /** + * Removes the dismiss target and cancels any pending callbacks to show it. + */ + public void cleanUpDismissTarget() { + if (mTargetViewContainer.getParent() != null) { + mWindowManager.removeViewImmediate(mTargetViewContainer); + } + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipMotionHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipMotionHelper.java new file mode 100644 index 000000000000..619bed4e19ca --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipMotionHelper.java @@ -0,0 +1,719 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.pip2.phone; + +import static androidx.dynamicanimation.animation.SpringForce.DAMPING_RATIO_NO_BOUNCY; +import static androidx.dynamicanimation.animation.SpringForce.STIFFNESS_LOW; +import static androidx.dynamicanimation.animation.SpringForce.STIFFNESS_MEDIUM; + +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_LEFT; +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_NONE; +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_RIGHT; +import static com.android.wm.shell.pip2.phone.PipMenuView.ANIM_TYPE_DISMISS; +import static com.android.wm.shell.pip2.phone.PipMenuView.ANIM_TYPE_NONE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Debug; + +import com.android.internal.protolog.common.ProtoLog; +import com.android.wm.shell.R; +import com.android.wm.shell.animation.FloatProperties; +import com.android.wm.shell.common.FloatingContentCoordinator; +import com.android.wm.shell.common.magnetictarget.MagnetizedObject; +import com.android.wm.shell.common.pip.PipAppOpsListener; +import com.android.wm.shell.common.pip.PipBoundsState; +import com.android.wm.shell.common.pip.PipPerfHintController; +import com.android.wm.shell.common.pip.PipSnapAlgorithm; +import com.android.wm.shell.protolog.ShellProtoLogGroup; +import com.android.wm.shell.shared.animation.PhysicsAnimator; + +import kotlin.Unit; +import kotlin.jvm.functions.Function0; + +import java.util.Optional; +import java.util.function.Consumer; + +/** + * A helper to animate and manipulate the PiP. + */ +public class PipMotionHelper implements PipAppOpsListener.Callback, + FloatingContentCoordinator.FloatingContent { + private static final String TAG = "PipMotionHelper"; + private static final boolean DEBUG = false; + + private static final int SHRINK_STACK_FROM_MENU_DURATION = 250; + private static final int EXPAND_STACK_TO_MENU_DURATION = 250; + private static final int UNSTASH_DURATION = 250; + private static final int LEAVE_PIP_DURATION = 300; + private static final int SHIFT_DURATION = 300; + + /** Friction to use for PIP when it moves via physics fling animations. */ + private static final float DEFAULT_FRICTION = 1.9f; + /** How much of the dismiss circle size to use when scaling down PIP. **/ + private static final float DISMISS_CIRCLE_PERCENT = 0.85f; + + private final Context mContext; + private @NonNull PipBoundsState mPipBoundsState; + + private PhonePipMenuController mMenuController; + private PipSnapAlgorithm mSnapAlgorithm; + + /** The region that all of PIP must stay within. */ + private final Rect mFloatingAllowedArea = new Rect(); + + /** Coordinator instance for resolving conflicts with other floating content. */ + private FloatingContentCoordinator mFloatingContentCoordinator; + + @Nullable private final PipPerfHintController mPipPerfHintController; + @Nullable private PipPerfHintController.PipHighPerfSession mPipHighPerfSession; + + /** + * PhysicsAnimator instance for animating {@link PipBoundsState#getMotionBoundsState()} + * using physics animations. + */ + private PhysicsAnimator mTemporaryBoundsPhysicsAnimator; + + private MagnetizedObject mMagnetizedPip; + + /** + * Update listener that resizes the PIP to {@link PipBoundsState#getMotionBoundsState()}. + */ + private final PhysicsAnimator.UpdateListener mResizePipUpdateListener; + + /** FlingConfig instances provided to PhysicsAnimator for fling gestures. */ + private PhysicsAnimator.FlingConfig mFlingConfigX; + private PhysicsAnimator.FlingConfig mFlingConfigY; + /** FlingConfig instances provided to PhysicsAnimator for stashing. */ + private PhysicsAnimator.FlingConfig mStashConfigX; + + /** SpringConfig to use for fling-then-spring animations. */ + private final PhysicsAnimator.SpringConfig mSpringConfig = + new PhysicsAnimator.SpringConfig(700f, DAMPING_RATIO_NO_BOUNCY); + + /** SpringConfig used for animating into the dismiss region, matches the one in + * {@link MagnetizedObject}. */ + private final PhysicsAnimator.SpringConfig mAnimateToDismissSpringConfig = + new PhysicsAnimator.SpringConfig(STIFFNESS_MEDIUM, DAMPING_RATIO_NO_BOUNCY); + + /** SpringConfig used for animating the pip to catch up to the finger once it leaves the dismiss + * drag region. */ + private final PhysicsAnimator.SpringConfig mCatchUpSpringConfig = + new PhysicsAnimator.SpringConfig(5000f, DAMPING_RATIO_NO_BOUNCY); + + /** SpringConfig to use for springing PIP away from conflicting floating content. */ + private final PhysicsAnimator.SpringConfig mConflictResolutionSpringConfig = + new PhysicsAnimator.SpringConfig(STIFFNESS_LOW, DAMPING_RATIO_NO_BOUNCY); + + private final Consumer mUpdateBoundsCallback = (Rect newBounds) -> { + if (mPipBoundsState.getBounds().equals(newBounds)) { + return; + } + + mMenuController.updateMenuLayout(newBounds); + mPipBoundsState.setBounds(newBounds); + }; + + /** + * Whether we're springing to the touch event location (vs. moving it to that position + * instantly). We spring-to-touch after PIP is dragged out of the magnetic target, since it was + * 'stuck' in the target and needs to catch up to the touch location. + */ + private boolean mSpringingToTouch = false; + + /** + * Whether PIP was released in the dismiss target, and will be animated out and dismissed + * shortly. + */ + private boolean mDismissalPending = false; + + /** + * Gets set in {@link #animateToExpandedState(Rect, Rect, Rect, Runnable)}, this callback is + * used to show menu activity when the expand animation is completed. + */ + private Runnable mPostPipTransitionCallback; + + public PipMotionHelper(Context context, @NonNull PipBoundsState pipBoundsState, + PhonePipMenuController menuController, PipSnapAlgorithm snapAlgorithm, + FloatingContentCoordinator floatingContentCoordinator, + Optional pipPerfHintControllerOptional) { + mContext = context; + mPipBoundsState = pipBoundsState; + mMenuController = menuController; + mSnapAlgorithm = snapAlgorithm; + mFloatingContentCoordinator = floatingContentCoordinator; + mPipPerfHintController = pipPerfHintControllerOptional.orElse(null); + mResizePipUpdateListener = (target, values) -> { + if (mPipBoundsState.getMotionBoundsState().isInMotion()) { + /* + mPipTaskOrganizer.scheduleUserResizePip(getBounds(), + mPipBoundsState.getMotionBoundsState().getBoundsInMotion(), null); + */ + } + }; + } + + void init() { + mTemporaryBoundsPhysicsAnimator = PhysicsAnimator.getInstance( + mPipBoundsState.getMotionBoundsState().getBoundsInMotion()); + } + + @NonNull + @Override + public Rect getFloatingBoundsOnScreen() { + return !mPipBoundsState.getMotionBoundsState().getAnimatingToBounds().isEmpty() + ? mPipBoundsState.getMotionBoundsState().getAnimatingToBounds() : getBounds(); + } + + @NonNull + @Override + public Rect getAllowedFloatingBoundsRegion() { + return mFloatingAllowedArea; + } + + @Override + public void moveToBounds(@NonNull Rect bounds) { + animateToBounds(bounds, mConflictResolutionSpringConfig); + } + + /** + * Synchronizes the current bounds with the pinned stack, cancelling any ongoing animations. + */ + void synchronizePinnedStackBounds() { + cancelPhysicsAnimation(); + mPipBoundsState.getMotionBoundsState().onAllAnimationsEnded(); + + /* + if (mPipTaskOrganizer.isInPip()) { + mFloatingContentCoordinator.onContentMoved(this); + } + */ + } + + /** + * Tries to move the pinned stack to the given {@param bounds}. + */ + void movePip(Rect toBounds) { + movePip(toBounds, false /* isDragging */); + } + + /** + * Tries to move the pinned stack to the given {@param bounds}. + * + * @param isDragging Whether this movement is the result of a drag touch gesture. If so, we + * won't notify the floating content coordinator of this move, since that will + * happen when the gesture ends. + */ + void movePip(Rect toBounds, boolean isDragging) { + if (!isDragging) { + mFloatingContentCoordinator.onContentMoved(this); + } + + if (!mSpringingToTouch) { + // If we are moving PIP directly to the touch event locations, cancel any animations and + // move PIP to the given bounds. + cancelPhysicsAnimation(); + + if (!isDragging) { + resizePipUnchecked(toBounds); + mPipBoundsState.setBounds(toBounds); + } else { + mPipBoundsState.getMotionBoundsState().setBoundsInMotion(toBounds); + /* + mPipTaskOrganizer.scheduleUserResizePip(getBounds(), toBounds, + (Rect newBounds) -> { + mMenuController.updateMenuLayout(newBounds); + }); + */ + } + } else { + // If PIP is 'catching up' after being stuck in the dismiss target, update the animation + // to spring towards the new touch location. + mTemporaryBoundsPhysicsAnimator + .spring(FloatProperties.RECT_WIDTH, getBounds().width(), mCatchUpSpringConfig) + .spring(FloatProperties.RECT_HEIGHT, getBounds().height(), mCatchUpSpringConfig) + .spring(FloatProperties.RECT_X, toBounds.left, mCatchUpSpringConfig) + .spring(FloatProperties.RECT_Y, toBounds.top, mCatchUpSpringConfig); + + startBoundsAnimator(toBounds.left /* toX */, toBounds.top /* toY */); + } + } + + /** Animates the PIP into the dismiss target, scaling it down. */ + void animateIntoDismissTarget( + MagnetizedObject.MagneticTarget target, + float velX, float velY, + boolean flung, Function0 after) { + final PointF targetCenter = target.getCenterOnScreen(); + + // PIP should fit in the circle + final float dismissCircleSize = mContext.getResources().getDimensionPixelSize( + R.dimen.dismiss_circle_size); + + final float width = getBounds().width(); + final float height = getBounds().height(); + final float ratio = width / height; + + // Width should be a little smaller than the circle size. + final float desiredWidth = dismissCircleSize * DISMISS_CIRCLE_PERCENT; + final float desiredHeight = desiredWidth / ratio; + final float destinationX = targetCenter.x - (desiredWidth / 2f); + final float destinationY = targetCenter.y - (desiredHeight / 2f); + + // If we're already in the dismiss target area, then there won't be a move to set the + // temporary bounds, so just initialize it to the current bounds. + if (!mPipBoundsState.getMotionBoundsState().isInMotion()) { + mPipBoundsState.getMotionBoundsState().setBoundsInMotion(getBounds()); + } + mTemporaryBoundsPhysicsAnimator + .spring(FloatProperties.RECT_X, destinationX, velX, mAnimateToDismissSpringConfig) + .spring(FloatProperties.RECT_Y, destinationY, velY, mAnimateToDismissSpringConfig) + .spring(FloatProperties.RECT_WIDTH, desiredWidth, mAnimateToDismissSpringConfig) + .spring(FloatProperties.RECT_HEIGHT, desiredHeight, mAnimateToDismissSpringConfig) + .withEndActions(after); + + startBoundsAnimator(destinationX, destinationY); + } + + /** Set whether we're springing-to-touch to catch up after being stuck in the dismiss target. */ + void setSpringingToTouch(boolean springingToTouch) { + mSpringingToTouch = springingToTouch; + } + + /** + * Resizes the pinned stack back to unknown windowing mode, which could be freeform or + * * fullscreen depending on the display area's windowing mode. + */ + void expandLeavePip(boolean skipAnimation) { + expandLeavePip(skipAnimation, false /* enterSplit */); + } + + /** + * Resizes the pinned task to split-screen mode. + */ + void expandIntoSplit() { + expandLeavePip(false, true /* enterSplit */); + } + + /** + * Resizes the pinned stack back to unknown windowing mode, which could be freeform or + * fullscreen depending on the display area's windowing mode. + */ + private void expandLeavePip(boolean skipAnimation, boolean enterSplit) { + if (DEBUG) { + ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, + "%s: exitPip: skipAnimation=%s" + + " callers=\n%s", TAG, skipAnimation, Debug.getCallers(5, " ")); + } + cancelPhysicsAnimation(); + mMenuController.hideMenu(ANIM_TYPE_NONE, false /* resize */); + // mPipTaskOrganizer.exitPip(skipAnimation ? 0 : LEAVE_PIP_DURATION, enterSplit); + } + + /** + * Dismisses the pinned stack. + */ + @Override + public void dismissPip() { + if (DEBUG) { + ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, + "%s: removePip: callers=\n%s", TAG, Debug.getCallers(5, " ")); + } + cancelPhysicsAnimation(); + mMenuController.hideMenu(ANIM_TYPE_DISMISS, false /* resize */); + // mPipTaskOrganizer.removePip(); + } + + /** Sets the movement bounds to use to constrain PIP position animations. */ + void onMovementBoundsChanged() { + rebuildFlingConfigs(); + + // The movement bounds represent the area within which we can move PIP's top-left position. + // The allowed area for all of PIP is those bounds plus PIP's width and height. + mFloatingAllowedArea.set(mPipBoundsState.getMovementBounds()); + mFloatingAllowedArea.right += getBounds().width(); + mFloatingAllowedArea.bottom += getBounds().height(); + } + + /** + * @return the PiP bounds. + */ + private Rect getBounds() { + return mPipBoundsState.getBounds(); + } + + /** + * Flings the PiP to the closest snap target. + */ + void flingToSnapTarget( + float velocityX, float velocityY, @Nullable Runnable postBoundsUpdateCallback) { + movetoTarget(velocityX, velocityY, postBoundsUpdateCallback, false /* isStash */); + } + + /** + * Stash PiP to the closest edge. We set velocityY to 0 to limit pure horizontal motion. + */ + void stashToEdge(float velX, float velY, @Nullable Runnable postBoundsUpdateCallback) { + velY = mPipBoundsState.getStashedState() == STASH_TYPE_NONE ? 0 : velY; + movetoTarget(velX, velY, postBoundsUpdateCallback, true /* isStash */); + } + + private void onHighPerfSessionTimeout(PipPerfHintController.PipHighPerfSession session) {} + + private void cleanUpHighPerfSessionMaybe() { + if (mPipHighPerfSession != null) { + // Close the high perf session once pointer interactions are over; + mPipHighPerfSession.close(); + mPipHighPerfSession = null; + } + } + + private void movetoTarget( + float velocityX, + float velocityY, + @Nullable Runnable postBoundsUpdateCallback, + boolean isStash) { + // If we're flinging to a snap target now, we're not springing to catch up to the touch + // location now. + mSpringingToTouch = false; + + mTemporaryBoundsPhysicsAnimator + .spring(FloatProperties.RECT_WIDTH, getBounds().width(), mSpringConfig) + .spring(FloatProperties.RECT_HEIGHT, getBounds().height(), mSpringConfig) + .flingThenSpring( + FloatProperties.RECT_X, velocityX, + isStash ? mStashConfigX : mFlingConfigX, + mSpringConfig, true /* flingMustReachMinOrMax */) + .flingThenSpring( + FloatProperties.RECT_Y, velocityY, mFlingConfigY, mSpringConfig); + + final Rect insetBounds = mPipBoundsState.getDisplayLayout().stableInsets(); + final float leftEdge = isStash + ? mPipBoundsState.getStashOffset() - mPipBoundsState.getBounds().width() + + insetBounds.left + : mPipBoundsState.getMovementBounds().left; + final float rightEdge = isStash + ? mPipBoundsState.getDisplayBounds().right - mPipBoundsState.getStashOffset() + - insetBounds.right + : mPipBoundsState.getMovementBounds().right; + + final float xEndValue = velocityX < 0 ? leftEdge : rightEdge; + + final int startValueY = mPipBoundsState.getMotionBoundsState().getBoundsInMotion().top; + final float estimatedFlingYEndValue = + PhysicsAnimator.estimateFlingEndValue(startValueY, velocityY, mFlingConfigY); + + startBoundsAnimator(xEndValue /* toX */, estimatedFlingYEndValue /* toY */, + postBoundsUpdateCallback); + } + + /** + * Animates PIP to the provided bounds, using physics animations and the given spring + * configuration + */ + void animateToBounds(Rect bounds, PhysicsAnimator.SpringConfig springConfig) { + if (!mTemporaryBoundsPhysicsAnimator.isRunning()) { + // Animate from the current bounds if we're not already animating. + mPipBoundsState.getMotionBoundsState().setBoundsInMotion(getBounds()); + } + + mTemporaryBoundsPhysicsAnimator + .spring(FloatProperties.RECT_X, bounds.left, springConfig) + .spring(FloatProperties.RECT_Y, bounds.top, springConfig); + startBoundsAnimator(bounds.left /* toX */, bounds.top /* toY */); + } + + /** + * Animates the dismissal of the PiP off the edge of the screen. + */ + void animateDismiss() { + // Animate off the bottom of the screen, then dismiss PIP. + mTemporaryBoundsPhysicsAnimator + .spring(FloatProperties.RECT_Y, + mPipBoundsState.getMovementBounds().bottom + getBounds().height() * 2, + 0, + mSpringConfig) + .withEndActions(this::dismissPip); + + startBoundsAnimator( + getBounds().left /* toX */, getBounds().bottom + getBounds().height() /* toY */); + + mDismissalPending = false; + } + + /** + * Animates the PiP to the expanded state to show the menu. + */ + float animateToExpandedState(Rect expandedBounds, Rect movementBounds, + Rect expandedMovementBounds, Runnable callback) { + float savedSnapFraction = mSnapAlgorithm.getSnapFraction(new Rect(getBounds()), + movementBounds); + mSnapAlgorithm.applySnapFraction(expandedBounds, expandedMovementBounds, savedSnapFraction); + mPostPipTransitionCallback = callback; + resizeAndAnimatePipUnchecked(expandedBounds, EXPAND_STACK_TO_MENU_DURATION); + return savedSnapFraction; + } + + /** + * Animates the PiP from the expanded state to the normal state after the menu is hidden. + */ + void animateToUnexpandedState(Rect normalBounds, float savedSnapFraction, + Rect normalMovementBounds, Rect currentMovementBounds, boolean immediate) { + if (savedSnapFraction < 0f) { + // If there are no saved snap fractions, then just use the current bounds + savedSnapFraction = mSnapAlgorithm.getSnapFraction(new Rect(getBounds()), + currentMovementBounds, mPipBoundsState.getStashedState()); + } + + mSnapAlgorithm.applySnapFraction(normalBounds, normalMovementBounds, savedSnapFraction, + mPipBoundsState.getStashedState(), mPipBoundsState.getStashOffset(), + mPipBoundsState.getDisplayBounds(), + mPipBoundsState.getDisplayLayout().stableInsets()); + + if (immediate) { + movePip(normalBounds); + } else { + resizeAndAnimatePipUnchecked(normalBounds, SHRINK_STACK_FROM_MENU_DURATION); + } + } + + /** + * Animates the PiP to the stashed state, choosing the closest edge. + */ + void animateToStashedClosestEdge() { + Rect tmpBounds = new Rect(); + final Rect insetBounds = mPipBoundsState.getDisplayLayout().stableInsets(); + final int stashType = + mPipBoundsState.getBounds().left == mPipBoundsState.getMovementBounds().left + ? STASH_TYPE_LEFT : STASH_TYPE_RIGHT; + final float leftEdge = stashType == STASH_TYPE_LEFT + ? mPipBoundsState.getStashOffset() + - mPipBoundsState.getBounds().width() + insetBounds.left + : mPipBoundsState.getDisplayBounds().right + - mPipBoundsState.getStashOffset() - insetBounds.right; + tmpBounds.set((int) leftEdge, + mPipBoundsState.getBounds().top, + (int) (leftEdge + mPipBoundsState.getBounds().width()), + mPipBoundsState.getBounds().bottom); + resizeAndAnimatePipUnchecked(tmpBounds, UNSTASH_DURATION); + mPipBoundsState.setStashed(stashType); + } + + /** + * Animates the PiP from stashed state into un-stashed, popping it out from the edge. + */ + void animateToUnStashedBounds(Rect unstashedBounds) { + resizeAndAnimatePipUnchecked(unstashedBounds, UNSTASH_DURATION); + } + + /** + * Animates the PiP to offset it from the IME or shelf. + */ + void animateToOffset(Rect originalBounds, int offset) { + if (DEBUG) { + ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, + "%s: animateToOffset: originalBounds=%s offset=%s" + + " callers=\n%s", TAG, originalBounds, offset, + Debug.getCallers(5, " ")); + } + cancelPhysicsAnimation(); + /* + mPipTaskOrganizer.scheduleOffsetPip(originalBounds, offset, SHIFT_DURATION, + mUpdateBoundsCallback); + */ + } + + /** + * Cancels all existing animations. + */ + private void cancelPhysicsAnimation() { + mTemporaryBoundsPhysicsAnimator.cancel(); + mPipBoundsState.getMotionBoundsState().onPhysicsAnimationEnded(); + mSpringingToTouch = false; + } + + /** Set new fling configs whose min/max values respect the given movement bounds. */ + private void rebuildFlingConfigs() { + mFlingConfigX = new PhysicsAnimator.FlingConfig(DEFAULT_FRICTION, + mPipBoundsState.getMovementBounds().left, + mPipBoundsState.getMovementBounds().right); + mFlingConfigY = new PhysicsAnimator.FlingConfig(DEFAULT_FRICTION, + mPipBoundsState.getMovementBounds().top, + mPipBoundsState.getMovementBounds().bottom); + final Rect insetBounds = mPipBoundsState.getDisplayLayout().stableInsets(); + mStashConfigX = new PhysicsAnimator.FlingConfig( + DEFAULT_FRICTION, + mPipBoundsState.getStashOffset() - mPipBoundsState.getBounds().width() + + insetBounds.left, + mPipBoundsState.getDisplayBounds().right - mPipBoundsState.getStashOffset() + - insetBounds.right); + } + + private void startBoundsAnimator(float toX, float toY) { + startBoundsAnimator(toX, toY, null /* postBoundsUpdateCallback */); + } + + /** + * Starts the physics animator which will update the animated PIP bounds using physics + * animations, as well as the TimeAnimator which will apply those bounds to PIP. + * + * This will also add end actions to the bounds animator that cancel the TimeAnimator and update + * the 'real' bounds to equal the final animated bounds. + * + * If one wishes to supply a callback after all the 'real' bounds update has happened, + * pass @param postBoundsUpdateCallback. + */ + private void startBoundsAnimator(float toX, float toY, Runnable postBoundsUpdateCallback) { + if (!mSpringingToTouch) { + cancelPhysicsAnimation(); + } + + setAnimatingToBounds(new Rect( + (int) toX, + (int) toY, + (int) toX + getBounds().width(), + (int) toY + getBounds().height())); + + if (!mTemporaryBoundsPhysicsAnimator.isRunning()) { + if (mPipPerfHintController != null) { + // Start a high perf session with a timeout callback. + mPipHighPerfSession = mPipPerfHintController.startSession( + this::onHighPerfSessionTimeout, "startBoundsAnimator"); + } + if (postBoundsUpdateCallback != null) { + mTemporaryBoundsPhysicsAnimator + .addUpdateListener(mResizePipUpdateListener) + .withEndActions(this::onBoundsPhysicsAnimationEnd, + postBoundsUpdateCallback); + } else { + mTemporaryBoundsPhysicsAnimator + .addUpdateListener(mResizePipUpdateListener) + .withEndActions(this::onBoundsPhysicsAnimationEnd); + } + } + + mTemporaryBoundsPhysicsAnimator.start(); + } + + /** + * Notify that PIP was released in the dismiss target and will be animated out and dismissed + * shortly. + */ + void notifyDismissalPending() { + mDismissalPending = true; + } + + private void onBoundsPhysicsAnimationEnd() { + // The physics animation ended, though we may not necessarily be done animating, such as + // when we're still dragging after moving out of the magnetic target. + if (!mDismissalPending + && !mSpringingToTouch + && !mMagnetizedPip.getObjectStuckToTarget()) { + // All motion operations have actually finished. + mPipBoundsState.setBounds( + mPipBoundsState.getMotionBoundsState().getBoundsInMotion()); + mPipBoundsState.getMotionBoundsState().onAllAnimationsEnded(); + if (!mDismissalPending) { + // do not schedule resize if PiP is dismissing, which may cause app re-open to + // mBounds instead of its normal bounds. + // mPipTaskOrganizer.scheduleFinishResizePip(getBounds()); + } + } + mPipBoundsState.getMotionBoundsState().onPhysicsAnimationEnded(); + mSpringingToTouch = false; + mDismissalPending = false; + cleanUpHighPerfSessionMaybe(); + } + + /** + * Notifies the floating coordinator that we're moving, and sets the animating to bounds so + * we return these bounds from + * {@link FloatingContentCoordinator.FloatingContent#getFloatingBoundsOnScreen()}. + */ + private void setAnimatingToBounds(Rect bounds) { + mPipBoundsState.getMotionBoundsState().setAnimatingToBounds(bounds); + mFloatingContentCoordinator.onContentMoved(this); + } + + /** + * Directly resizes the PiP to the given {@param bounds}. + */ + private void resizePipUnchecked(Rect toBounds) { + if (DEBUG) { + ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, + "%s: resizePipUnchecked: toBounds=%s" + + " callers=\n%s", TAG, toBounds, Debug.getCallers(5, " ")); + } + if (!toBounds.equals(getBounds())) { + // mPipTaskOrganizer.scheduleResizePip(toBounds, mUpdateBoundsCallback); + } + } + + /** + * Directly resizes the PiP to the given {@param bounds}. + */ + private void resizeAndAnimatePipUnchecked(Rect toBounds, int duration) { + if (DEBUG) { + ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, + "%s: resizeAndAnimatePipUnchecked: toBounds=%s" + + " duration=%s callers=\n%s", TAG, toBounds, duration, + Debug.getCallers(5, " ")); + } + + // Intentionally resize here even if the current bounds match the destination bounds. + // This is so all the proper callbacks are performed. + + // mPipTaskOrganizer.scheduleAnimateResizePip(toBounds, duration, + // TRANSITION_DIRECTION_EXPAND_OR_UNEXPAND, null /* updateBoundsCallback */); + // setAnimatingToBounds(toBounds); + } + + /** + * Returns a MagnetizedObject wrapper for PIP's animated bounds. This is provided to the + * magnetic dismiss target so it can calculate PIP's size and position. + */ + MagnetizedObject getMagnetizedPip() { + if (mMagnetizedPip == null) { + mMagnetizedPip = new MagnetizedObject( + mContext, mPipBoundsState.getMotionBoundsState().getBoundsInMotion(), + FloatProperties.RECT_X, FloatProperties.RECT_Y) { + @Override + public float getWidth(@NonNull Rect animatedPipBounds) { + return animatedPipBounds.width(); + } + + @Override + public float getHeight(@NonNull Rect animatedPipBounds) { + return animatedPipBounds.height(); + } + + @Override + public void getLocationOnScreen( + @NonNull Rect animatedPipBounds, @NonNull int[] loc) { + loc[0] = animatedPipBounds.left; + loc[1] = animatedPipBounds.top; + } + }; + mMagnetizedPip.setFlingToTargetEnabled(false); + } + + return mMagnetizedPip; + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java new file mode 100644 index 000000000000..cc8e3e0934e6 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTouchHandler.java @@ -0,0 +1,1081 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.wm.shell.pip2.phone; + +import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.PIP_STASHING; +import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.PIP_STASH_MINIMUM_VELOCITY_THRESHOLD; +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_LEFT; +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_NONE; +import static com.android.wm.shell.common.pip.PipBoundsState.STASH_TYPE_RIGHT; +import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_TO_PIP; +import static com.android.wm.shell.pip2.phone.PhonePipMenuController.MENU_STATE_FULL; +import static com.android.wm.shell.pip2.phone.PhonePipMenuController.MENU_STATE_NONE; +import static com.android.wm.shell.pip2.phone.PipMenuView.ANIM_TYPE_NONE; +import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.SuppressLint; +import android.content.ComponentName; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.provider.DeviceConfig; +import android.util.Size; +import android.view.DisplayCutout; +import android.view.InputEvent; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.protolog.common.ProtoLog; +import com.android.wm.shell.R; +import com.android.wm.shell.common.FloatingContentCoordinator; +import com.android.wm.shell.common.ShellExecutor; +import com.android.wm.shell.common.pip.PipBoundsAlgorithm; +import com.android.wm.shell.common.pip.PipBoundsState; +import com.android.wm.shell.common.pip.PipDoubleTapHelper; +import com.android.wm.shell.common.pip.PipPerfHintController; +import com.android.wm.shell.common.pip.PipUiEventLogger; +import com.android.wm.shell.common.pip.PipUtils; +import com.android.wm.shell.common.pip.SizeSpecSource; +import com.android.wm.shell.pip.PipAnimationController; +import com.android.wm.shell.pip.PipTransitionController; +import com.android.wm.shell.sysui.ShellInit; + +import java.io.PrintWriter; +import java.util.Optional; + +/** + * Manages all the touch handling for PIP on the Phone, including moving, dismissing and expanding + * the PIP. + */ +public class PipTouchHandler { + + private static final String TAG = "PipTouchHandler"; + private static final float DEFAULT_STASH_VELOCITY_THRESHOLD = 18000.f; + + // Allow PIP to resize to a slightly bigger state upon touch + private boolean mEnableResize; + private final Context mContext; + private final PipBoundsAlgorithm mPipBoundsAlgorithm; + @NonNull private final PipBoundsState mPipBoundsState; + @NonNull private final SizeSpecSource mSizeSpecSource; + private final PipUiEventLogger mPipUiEventLogger; + private final PipDismissTargetHandler mPipDismissTargetHandler; + private final ShellExecutor mMainExecutor; + @Nullable private final PipPerfHintController mPipPerfHintController; + + private PipResizeGestureHandler mPipResizeGestureHandler; + + private final PhonePipMenuController mMenuController; + private final AccessibilityManager mAccessibilityManager; + + /** + * Whether PIP stash is enabled or not. When enabled, if the user flings toward the edge of the + * screen, it will be shown in "stashed" mode, where PIP will only show partially. + */ + private boolean mEnableStash = true; + + private float mStashVelocityThreshold; + + // The reference inset bounds, used to determine the dismiss fraction + private final Rect mInsetBounds = new Rect(); + + // Used to workaround an issue where the WM rotation happens before we are notified, allowing + // us to send stale bounds + private int mDeferResizeToNormalBoundsUntilRotation = -1; + private int mDisplayRotation; + + // Behaviour states + private int mMenuState = MENU_STATE_NONE; + private boolean mIsImeShowing; + private int mImeHeight; + private int mImeOffset; + private boolean mIsShelfShowing; + private int mShelfHeight; + private int mMovementBoundsExtraOffsets; + private int mBottomOffsetBufferPx; + private float mSavedSnapFraction = -1f; + private boolean mSendingHoverAccessibilityEvents; + private boolean mMovementWithinDismiss; + + // Touch state + private final PipTouchState mTouchState; + private final FloatingContentCoordinator mFloatingContentCoordinator; + private PipMotionHelper mMotionHelper; + private PipTouchGesture mGesture; + + // Temp vars + private final Rect mTmpBounds = new Rect(); + + /** + * A listener for the PIP menu activity. + */ + private class PipMenuListener implements PhonePipMenuController.Listener { + @Override + public void onPipMenuStateChangeStart(int menuState, boolean resize, Runnable callback) { + PipTouchHandler.this.onPipMenuStateChangeStart(menuState, resize, callback); + } + + @Override + public void onPipMenuStateChangeFinish(int menuState) { + setMenuState(menuState); + } + + @Override + public void onPipExpand() { + mMotionHelper.expandLeavePip(false /* skipAnimation */); + } + + @Override + public void onPipDismiss() { + mTouchState.removeDoubleTapTimeoutCallback(); + mMotionHelper.dismissPip(); + } + + @Override + public void onPipShowMenu() { + mMenuController.showMenu(MENU_STATE_FULL, mPipBoundsState.getBounds(), + true /* allowMenuTimeout */, willResizeMenu(), shouldShowResizeHandle()); + } + } + + @SuppressLint("InflateParams") + public PipTouchHandler(Context context, + ShellInit shellInit, + PhonePipMenuController menuController, + PipBoundsAlgorithm pipBoundsAlgorithm, + @NonNull PipBoundsState pipBoundsState, + @NonNull SizeSpecSource sizeSpecSource, + PipMotionHelper pipMotionHelper, + FloatingContentCoordinator floatingContentCoordinator, + PipUiEventLogger pipUiEventLogger, + ShellExecutor mainExecutor, + Optional pipPerfHintControllerOptional) { + mContext = context; + mMainExecutor = mainExecutor; + mPipPerfHintController = pipPerfHintControllerOptional.orElse(null); + mAccessibilityManager = context.getSystemService(AccessibilityManager.class); + mPipBoundsAlgorithm = pipBoundsAlgorithm; + mPipBoundsState = pipBoundsState; + mSizeSpecSource = sizeSpecSource; + mMenuController = menuController; + mPipUiEventLogger = pipUiEventLogger; + mFloatingContentCoordinator = floatingContentCoordinator; + mMenuController.addListener(new PipMenuListener()); + mGesture = new DefaultPipTouchGesture(); + mMotionHelper = pipMotionHelper; + mPipDismissTargetHandler = new PipDismissTargetHandler(context, pipUiEventLogger, + mMotionHelper, mainExecutor); + mTouchState = new PipTouchState(ViewConfiguration.get(context), + () -> { + if (mPipBoundsState.isStashed()) { + animateToUnStashedState(); + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_STASH_UNSTASHED); + mPipBoundsState.setStashed(STASH_TYPE_NONE); + } else { + mMenuController.showMenuWithPossibleDelay(MENU_STATE_FULL, + mPipBoundsState.getBounds(), true /* allowMenuTimeout */, + willResizeMenu(), + shouldShowResizeHandle()); + } + }, + menuController::hideMenu, + mainExecutor); + mPipResizeGestureHandler = + new PipResizeGestureHandler(context, pipBoundsAlgorithm, pipBoundsState, + mTouchState, this::updateMovementBounds, pipUiEventLogger, + menuController, mainExecutor, mPipPerfHintController); + + if (PipUtils.isPip2ExperimentEnabled()) { + shellInit.addInitCallback(this::onInit, this); + } + } + + /** + * Called when the touch handler is initialized. + */ + public void onInit() { + Resources res = mContext.getResources(); + mEnableResize = res.getBoolean(R.bool.config_pipEnableResizeForMenu); + reloadResources(); + + mMotionHelper.init(); + mPipResizeGestureHandler.init(); + mPipDismissTargetHandler.init(); + + mEnableStash = DeviceConfig.getBoolean( + DeviceConfig.NAMESPACE_SYSTEMUI, + PIP_STASHING, + /* defaultValue = */ true); + DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_SYSTEMUI, + mMainExecutor, + properties -> { + if (properties.getKeyset().contains(PIP_STASHING)) { + mEnableStash = properties.getBoolean( + PIP_STASHING, /* defaultValue = */ true); + } + }); + mStashVelocityThreshold = DeviceConfig.getFloat( + DeviceConfig.NAMESPACE_SYSTEMUI, + PIP_STASH_MINIMUM_VELOCITY_THRESHOLD, + DEFAULT_STASH_VELOCITY_THRESHOLD); + DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_SYSTEMUI, + mMainExecutor, + properties -> { + if (properties.getKeyset().contains(PIP_STASH_MINIMUM_VELOCITY_THRESHOLD)) { + mStashVelocityThreshold = properties.getFloat( + PIP_STASH_MINIMUM_VELOCITY_THRESHOLD, + DEFAULT_STASH_VELOCITY_THRESHOLD); + } + }); + } + + public PipTransitionController getTransitionHandler() { + // return mPipTaskOrganizer.getTransitionController(); + return null; + } + + private void reloadResources() { + final Resources res = mContext.getResources(); + mBottomOffsetBufferPx = res.getDimensionPixelSize(R.dimen.pip_bottom_offset_buffer); + mImeOffset = res.getDimensionPixelSize(R.dimen.pip_ime_offset); + mPipDismissTargetHandler.updateMagneticTargetSize(); + } + + void onOverlayChanged() { + // onOverlayChanged is triggered upon theme change, update the dismiss target accordingly. + mPipDismissTargetHandler.init(); + } + + private boolean shouldShowResizeHandle() { + return false; + } + + void setTouchGesture(PipTouchGesture gesture) { + mGesture = gesture; + } + + void setTouchEnabled(boolean enabled) { + mTouchState.setAllowTouches(enabled); + } + + void showPictureInPictureMenu() { + // Only show the menu if the user isn't currently interacting with the PiP + if (!mTouchState.isUserInteracting()) { + mMenuController.showMenu(MENU_STATE_FULL, mPipBoundsState.getBounds(), + false /* allowMenuTimeout */, willResizeMenu(), + shouldShowResizeHandle()); + } + } + + void onActivityPinned() { + mPipDismissTargetHandler.createOrUpdateDismissTarget(); + + mPipResizeGestureHandler.onActivityPinned(); + mFloatingContentCoordinator.onContentAdded(mMotionHelper); + } + + void onActivityUnpinned(ComponentName topPipActivity) { + if (topPipActivity == null) { + // Clean up state after the last PiP activity is removed + mPipDismissTargetHandler.cleanUpDismissTarget(); + + mFloatingContentCoordinator.onContentRemoved(mMotionHelper); + } + mPipResizeGestureHandler.onActivityUnpinned(); + } + + void onPinnedStackAnimationEnded( + @PipAnimationController.TransitionDirection int direction) { + // Always synchronize the motion helper bounds once PiP animations finish + mMotionHelper.synchronizePinnedStackBounds(); + updateMovementBounds(); + if (direction == TRANSITION_DIRECTION_TO_PIP) { + // Set the initial bounds as the user resize bounds. + mPipResizeGestureHandler.setUserResizeBounds(mPipBoundsState.getBounds()); + } + } + + void onConfigurationChanged() { + mPipResizeGestureHandler.onConfigurationChanged(); + mMotionHelper.synchronizePinnedStackBounds(); + reloadResources(); + + /* + if (mPipTaskOrganizer.isInPip()) { + // Recreate the dismiss target for the new orientation. + mPipDismissTargetHandler.createOrUpdateDismissTarget(); + } + */ + } + + void onImeVisibilityChanged(boolean imeVisible, int imeHeight) { + mIsImeShowing = imeVisible; + mImeHeight = imeHeight; + } + + void onShelfVisibilityChanged(boolean shelfVisible, int shelfHeight) { + mIsShelfShowing = shelfVisible; + mShelfHeight = shelfHeight; + } + + /** + * Called when SysUI state changed. + * + * @param isSysUiStateValid Is SysUI valid or not. + */ + public void onSystemUiStateChanged(boolean isSysUiStateValid) { + mPipResizeGestureHandler.onSystemUiStateChanged(isSysUiStateValid); + } + + void adjustBoundsForRotation(Rect outBounds, Rect curBounds, Rect insetBounds) { + final Rect toMovementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds(outBounds, insetBounds, toMovementBounds, 0); + final int prevBottom = mPipBoundsState.getMovementBounds().bottom + - mMovementBoundsExtraOffsets; + if ((prevBottom - mBottomOffsetBufferPx) <= curBounds.top) { + outBounds.offsetTo(outBounds.left, toMovementBounds.bottom); + } + } + + /** + * Responds to IPinnedStackListener on resetting aspect ratio for the pinned window. + */ + public void onAspectRatioChanged() { + mPipResizeGestureHandler.invalidateUserResizeBounds(); + } + + void onMovementBoundsChanged(Rect insetBounds, Rect normalBounds, Rect curBounds, + boolean fromImeAdjustment, boolean fromShelfAdjustment, int displayRotation) { + // Set the user resized bounds equal to the new normal bounds in case they were + // invalidated (e.g. by an aspect ratio change). + if (mPipResizeGestureHandler.getUserResizeBounds().isEmpty()) { + mPipResizeGestureHandler.setUserResizeBounds(normalBounds); + } + + final int bottomOffset = mIsImeShowing ? mImeHeight : 0; + final boolean fromDisplayRotationChanged = (mDisplayRotation != displayRotation); + if (fromDisplayRotationChanged) { + mTouchState.reset(); + } + + // Re-calculate the expanded bounds + Rect normalMovementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds(normalBounds, insetBounds, + normalMovementBounds, bottomOffset); + + if (mPipBoundsState.getMovementBounds().isEmpty()) { + // mMovementBounds is not initialized yet and a clean movement bounds without + // bottom offset shall be used later in this function. + mPipBoundsAlgorithm.getMovementBounds(curBounds, insetBounds, + mPipBoundsState.getMovementBounds(), 0 /* bottomOffset */); + } + + // Calculate the expanded size + float aspectRatio = (float) normalBounds.width() / normalBounds.height(); + Size expandedSize = mSizeSpecSource.getDefaultSize(aspectRatio); + mPipBoundsState.setExpandedBounds( + new Rect(0, 0, expandedSize.getWidth(), expandedSize.getHeight())); + Rect expandedMovementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds( + mPipBoundsState.getExpandedBounds(), insetBounds, expandedMovementBounds, + bottomOffset); + + updatePipSizeConstraints(normalBounds, aspectRatio); + + // The extra offset does not really affect the movement bounds, but are applied based on the + // current state (ime showing, or shelf offset) when we need to actually shift + int extraOffset = Math.max( + mIsImeShowing ? mImeOffset : 0, + !mIsImeShowing && mIsShelfShowing ? mShelfHeight : 0); + + // Update the movement bounds after doing the calculations based on the old movement bounds + // above + mPipBoundsState.setNormalMovementBounds(normalMovementBounds); + mPipBoundsState.setExpandedMovementBounds(expandedMovementBounds); + mDisplayRotation = displayRotation; + mInsetBounds.set(insetBounds); + updateMovementBounds(); + mMovementBoundsExtraOffsets = extraOffset; + + // If we have a deferred resize, apply it now + if (mDeferResizeToNormalBoundsUntilRotation == displayRotation) { + mMotionHelper.animateToUnexpandedState(normalBounds, mSavedSnapFraction, + mPipBoundsState.getNormalMovementBounds(), mPipBoundsState.getMovementBounds(), + true /* immediate */); + mSavedSnapFraction = -1f; + mDeferResizeToNormalBoundsUntilRotation = -1; + } + } + + /** + * Update the values for min/max allowed size of picture in picture window based on the aspect + * ratio. + * @param aspectRatio aspect ratio to use for the calculation of min/max size + */ + public void updateMinMaxSize(float aspectRatio) { + updatePipSizeConstraints(mPipBoundsState.getNormalBounds(), + aspectRatio); + } + + private void updatePipSizeConstraints(Rect normalBounds, + float aspectRatio) { + if (mPipResizeGestureHandler.isUsingPinchToZoom()) { + updatePinchResizeSizeConstraints(aspectRatio); + } else { + mPipResizeGestureHandler.updateMinSize(normalBounds.width(), normalBounds.height()); + mPipResizeGestureHandler.updateMaxSize(mPipBoundsState.getExpandedBounds().width(), + mPipBoundsState.getExpandedBounds().height()); + } + } + + private void updatePinchResizeSizeConstraints(float aspectRatio) { + mPipBoundsState.updateMinMaxSize(aspectRatio); + mPipResizeGestureHandler.updateMinSize(mPipBoundsState.getMinSize().x, + mPipBoundsState.getMinSize().y); + mPipResizeGestureHandler.updateMaxSize(mPipBoundsState.getMaxSize().x, + mPipBoundsState.getMaxSize().y); + } + + /** + * TODO Add appropriate description + */ + public void onRegistrationChanged(boolean isRegistered) { + if (isRegistered) { + // Register the accessibility connection. + } else { + mAccessibilityManager.setPictureInPictureActionReplacingConnection(null); + } + if (!isRegistered && mTouchState.isUserInteracting()) { + // If the input consumer is unregistered while the user is interacting, then we may not + // get the final TOUCH_UP event, so clean up the dismiss target as well + mPipDismissTargetHandler.cleanUpDismissTarget(); + } + } + + private void onAccessibilityShowMenu() { + mMenuController.showMenu(MENU_STATE_FULL, mPipBoundsState.getBounds(), + true /* allowMenuTimeout */, willResizeMenu(), + shouldShowResizeHandle()); + } + + /** + * TODO Add appropriate description + */ + public boolean handleTouchEvent(InputEvent inputEvent) { + // Skip any non motion events + if (!(inputEvent instanceof MotionEvent)) { + return true; + } + + // do not process input event if not allowed + if (!mTouchState.getAllowInputEvents()) { + return true; + } + + MotionEvent ev = (MotionEvent) inputEvent; + if (!mPipBoundsState.isStashed() && mPipResizeGestureHandler.willStartResizeGesture(ev)) { + // Initialize the touch state for the gesture, but immediately reset to invalidate the + // gesture + mTouchState.onTouchEvent(ev); + mTouchState.reset(); + return true; + } + + if (mPipResizeGestureHandler.hasOngoingGesture()) { + mGesture.cleanUpHighPerfSessionMaybe(); + mPipDismissTargetHandler.hideDismissTargetMaybe(); + return true; + } + + if ((ev.getAction() == MotionEvent.ACTION_DOWN || mTouchState.isUserInteracting()) + && mPipDismissTargetHandler.maybeConsumeMotionEvent(ev)) { + // If the first touch event occurs within the magnetic field, pass the ACTION_DOWN event + // to the touch state. Touch state needs a DOWN event in order to later process MOVE + // events it'll receive if the object is dragged out of the magnetic field. + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + mTouchState.onTouchEvent(ev); + } + + // Continue tracking velocity when the object is in the magnetic field, since we want to + // respect touch input velocity if the object is dragged out and then flung. + mTouchState.addMovementToVelocityTracker(ev); + + return true; + } + + if (!mTouchState.isUserInteracting()) { + ProtoLog.wtf(WM_SHELL_PICTURE_IN_PICTURE, + "%s: Waiting to start the entry animation, skip the motion event.", TAG); + return true; + } + + // Update the touch state + mTouchState.onTouchEvent(ev); + + boolean shouldDeliverToMenu = mMenuState != MENU_STATE_NONE; + + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: { + mGesture.onDown(mTouchState); + break; + } + case MotionEvent.ACTION_MOVE: { + if (mGesture.onMove(mTouchState)) { + break; + } + + shouldDeliverToMenu = !mTouchState.isDragging(); + break; + } + case MotionEvent.ACTION_UP: { + // Update the movement bounds again if the state has changed since the user started + // dragging (ie. when the IME shows) + updateMovementBounds(); + + if (mGesture.onUp(mTouchState)) { + break; + } + } + // Fall through to clean up + case MotionEvent.ACTION_CANCEL: { + shouldDeliverToMenu = !mTouchState.startedDragging() && !mTouchState.isDragging(); + mTouchState.reset(); + break; + } + case MotionEvent.ACTION_HOVER_ENTER: { + // If Touch Exploration is enabled, some a11y services (e.g. Talkback) is probably + // on and changing MotionEvents into HoverEvents. + // Let's not enable menu show/hide for a11y services. + if (!mAccessibilityManager.isTouchExplorationEnabled()) { + mTouchState.removeHoverExitTimeoutCallback(); + mMenuController.showMenu(MENU_STATE_FULL, mPipBoundsState.getBounds(), + false /* allowMenuTimeout */, false /* willResizeMenu */, + shouldShowResizeHandle()); + } + } + // Fall through + case MotionEvent.ACTION_HOVER_MOVE: { + if (!shouldDeliverToMenu && !mSendingHoverAccessibilityEvents) { + sendAccessibilityHoverEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER); + mSendingHoverAccessibilityEvents = true; + } + break; + } + case MotionEvent.ACTION_HOVER_EXIT: { + // If Touch Exploration is enabled, some a11y services (e.g. Talkback) is probably + // on and changing MotionEvents into HoverEvents. + // Let's not enable menu show/hide for a11y services. + if (!mAccessibilityManager.isTouchExplorationEnabled()) { + mTouchState.scheduleHoverExitTimeoutCallback(); + } + if (!shouldDeliverToMenu && mSendingHoverAccessibilityEvents) { + sendAccessibilityHoverEvent(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); + mSendingHoverAccessibilityEvents = false; + } + break; + } + } + + shouldDeliverToMenu &= !mPipBoundsState.isStashed(); + + // Deliver the event to PipMenuActivity to handle button click if the menu has shown. + if (shouldDeliverToMenu) { + final MotionEvent cloneEvent = MotionEvent.obtain(ev); + // Send the cancel event and cancel menu timeout if it starts to drag. + if (mTouchState.startedDragging()) { + cloneEvent.setAction(MotionEvent.ACTION_CANCEL); + mMenuController.pokeMenu(); + } + + mMenuController.handlePointerEvent(cloneEvent); + cloneEvent.recycle(); + } + + return true; + } + + private void sendAccessibilityHoverEvent(int type) { + if (!mAccessibilityManager.isEnabled()) { + return; + } + + AccessibilityEvent event = AccessibilityEvent.obtain(type); + event.setImportantForAccessibility(true); + event.setSourceNodeId(AccessibilityNodeInfo.ROOT_NODE_ID); + event.setWindowId( + AccessibilityWindowInfo.PICTURE_IN_PICTURE_ACTION_REPLACER_WINDOW_ID); + mAccessibilityManager.sendAccessibilityEvent(event); + } + + /** + * Called when the PiP menu state is in the process of animating/changing from one to another. + */ + private void onPipMenuStateChangeStart(int menuState, boolean resize, Runnable callback) { + if (mMenuState == menuState && !resize) { + return; + } + + if (menuState == MENU_STATE_FULL && mMenuState != MENU_STATE_FULL) { + // Save the current snap fraction and if we do not drag or move the PiP, then + // we store back to this snap fraction. Otherwise, we'll reset the snap + // fraction and snap to the closest edge. + if (resize) { + // PIP is too small to show the menu actions and thus needs to be resized to a + // size that can fit them all. Resize to the default size. + animateToNormalSize(callback); + } + } else if (menuState == MENU_STATE_NONE && mMenuState == MENU_STATE_FULL) { + // Try and restore the PiP to the closest edge, using the saved snap fraction + // if possible + if (resize && !mPipResizeGestureHandler.isResizing()) { + if (mDeferResizeToNormalBoundsUntilRotation == -1) { + // This is a very special case: when the menu is expanded and visible, + // navigating to another activity can trigger auto-enter PiP, and if the + // revealed activity has a forced rotation set, then the controller will get + // updated with the new rotation of the display. However, at the same time, + // SystemUI will try to hide the menu by creating an animation to the normal + // bounds which are now stale. In such a case we defer the animation to the + // normal bounds until after the next onMovementBoundsChanged() call to get the + // bounds in the new orientation + int displayRotation = mContext.getDisplay().getRotation(); + if (mDisplayRotation != displayRotation) { + mDeferResizeToNormalBoundsUntilRotation = displayRotation; + } + } + + if (mDeferResizeToNormalBoundsUntilRotation == -1) { + animateToUnexpandedState(getUserResizeBounds()); + } + } else { + mSavedSnapFraction = -1f; + } + } + } + + private void setMenuState(int menuState) { + mMenuState = menuState; + updateMovementBounds(); + // If pip menu has dismissed, we should register the A11y ActionReplacingConnection for pip + // as well, or it can't handle a11y focus and pip menu can't perform any action. + onRegistrationChanged(menuState == MENU_STATE_NONE); + if (menuState == MENU_STATE_NONE) { + mPipUiEventLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_HIDE_MENU); + } else if (menuState == MENU_STATE_FULL) { + mPipUiEventLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_SHOW_MENU); + } + } + + private void animateToMaximizedState(Runnable callback) { + Rect maxMovementBounds = new Rect(); + Rect maxBounds = new Rect(0, 0, mPipBoundsState.getMaxSize().x, + mPipBoundsState.getMaxSize().y); + mPipBoundsAlgorithm.getMovementBounds(maxBounds, mInsetBounds, maxMovementBounds, + mIsImeShowing ? mImeHeight : 0); + mSavedSnapFraction = mMotionHelper.animateToExpandedState(maxBounds, + mPipBoundsState.getMovementBounds(), maxMovementBounds, + callback); + } + + private void animateToNormalSize(Runnable callback) { + // Save the current bounds as the user-resize bounds. + mPipResizeGestureHandler.setUserResizeBounds(mPipBoundsState.getBounds()); + + final Size minMenuSize = mMenuController.getEstimatedMinMenuSize(); + final Rect normalBounds = mPipBoundsState.getNormalBounds(); + final Rect destBounds = mPipBoundsAlgorithm.adjustNormalBoundsToFitMenu(normalBounds, + minMenuSize); + Rect restoredMovementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds(destBounds, + mInsetBounds, restoredMovementBounds, mIsImeShowing ? mImeHeight : 0); + mSavedSnapFraction = mMotionHelper.animateToExpandedState(destBounds, + mPipBoundsState.getMovementBounds(), restoredMovementBounds, callback); + } + + private void animateToUnexpandedState(Rect restoreBounds) { + Rect restoredMovementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds(restoreBounds, + mInsetBounds, restoredMovementBounds, mIsImeShowing ? mImeHeight : 0); + mMotionHelper.animateToUnexpandedState(restoreBounds, mSavedSnapFraction, + restoredMovementBounds, mPipBoundsState.getMovementBounds(), false /* immediate */); + mSavedSnapFraction = -1f; + } + + private void animateToUnStashedState() { + final Rect pipBounds = mPipBoundsState.getBounds(); + final boolean onLeftEdge = pipBounds.left < mPipBoundsState.getDisplayBounds().left; + final Rect unStashedBounds = new Rect(0, pipBounds.top, 0, pipBounds.bottom); + unStashedBounds.left = onLeftEdge ? mInsetBounds.left + : mInsetBounds.right - pipBounds.width(); + unStashedBounds.right = onLeftEdge ? mInsetBounds.left + pipBounds.width() + : mInsetBounds.right; + mMotionHelper.animateToUnStashedBounds(unStashedBounds); + } + + /** + * @return the motion helper. + */ + public PipMotionHelper getMotionHelper() { + return mMotionHelper; + } + + @VisibleForTesting + public PipResizeGestureHandler getPipResizeGestureHandler() { + return mPipResizeGestureHandler; + } + + @VisibleForTesting + public void setPipResizeGestureHandler(PipResizeGestureHandler pipResizeGestureHandler) { + mPipResizeGestureHandler = pipResizeGestureHandler; + } + + @VisibleForTesting + public void setPipMotionHelper(PipMotionHelper pipMotionHelper) { + mMotionHelper = pipMotionHelper; + } + + Rect getUserResizeBounds() { + return mPipResizeGestureHandler.getUserResizeBounds(); + } + + /** + * Sets the user resize bounds tracked by {@link PipResizeGestureHandler} + */ + void setUserResizeBounds(Rect bounds) { + mPipResizeGestureHandler.setUserResizeBounds(bounds); + } + + /** + * Gesture controlling normal movement of the PIP. + */ + private class DefaultPipTouchGesture extends PipTouchGesture { + private final Point mStartPosition = new Point(); + private final PointF mDelta = new PointF(); + private boolean mShouldHideMenuAfterFling; + + @Nullable private PipPerfHintController.PipHighPerfSession mPipHighPerfSession; + + private void onHighPerfSessionTimeout(PipPerfHintController.PipHighPerfSession session) {} + + @Override + public void cleanUpHighPerfSessionMaybe() { + if (mPipHighPerfSession != null) { + // Close the high perf session once pointer interactions are over; + mPipHighPerfSession.close(); + mPipHighPerfSession = null; + } + } + + @Override + public void onDown(PipTouchState touchState) { + if (!touchState.isUserInteracting()) { + return; + } + + if (mPipPerfHintController != null) { + // Cache the PiP high perf session to close it upon touch up. + mPipHighPerfSession = mPipPerfHintController.startSession( + this::onHighPerfSessionTimeout, "DefaultPipTouchGesture#onDown"); + } + + Rect bounds = getPossiblyMotionBounds(); + mDelta.set(0f, 0f); + mStartPosition.set(bounds.left, bounds.top); + mMovementWithinDismiss = touchState.getDownTouchPosition().y + >= mPipBoundsState.getMovementBounds().bottom; + mMotionHelper.setSpringingToTouch(false); + // mPipDismissTargetHandler.setTaskLeash(mPipTaskOrganizer.getSurfaceControl()); + + // If the menu is still visible then just poke the menu + // so that it will timeout after the user stops touching it + if (mMenuState != MENU_STATE_NONE && !mPipBoundsState.isStashed()) { + mMenuController.pokeMenu(); + } + } + + @Override + public boolean onMove(PipTouchState touchState) { + if (!touchState.isUserInteracting()) { + return false; + } + + if (touchState.startedDragging()) { + mSavedSnapFraction = -1f; + mPipDismissTargetHandler.showDismissTargetMaybe(); + } + + if (touchState.isDragging()) { + mPipBoundsState.setHasUserMovedPip(true); + + // Move the pinned stack freely + final PointF lastDelta = touchState.getLastTouchDelta(); + float lastX = mStartPosition.x + mDelta.x; + float lastY = mStartPosition.y + mDelta.y; + float left = lastX + lastDelta.x; + float top = lastY + lastDelta.y; + + // Add to the cumulative delta after bounding the position + mDelta.x += left - lastX; + mDelta.y += top - lastY; + + mTmpBounds.set(getPossiblyMotionBounds()); + mTmpBounds.offsetTo((int) left, (int) top); + mMotionHelper.movePip(mTmpBounds, true /* isDragging */); + + final PointF curPos = touchState.getLastTouchPosition(); + if (mMovementWithinDismiss) { + // Track if movement remains near the bottom edge to identify swipe to dismiss + mMovementWithinDismiss = curPos.y >= mPipBoundsState.getMovementBounds().bottom; + } + return true; + } + return false; + } + + @Override + public boolean onUp(PipTouchState touchState) { + mPipDismissTargetHandler.hideDismissTargetMaybe(); + mPipDismissTargetHandler.setTaskLeash(null); + + if (!touchState.isUserInteracting()) { + return false; + } + + final PointF vel = touchState.getVelocity(); + + if (touchState.isDragging()) { + if (mMenuState != MENU_STATE_NONE) { + // If the menu is still visible, then just poke the menu so that + // it will timeout after the user stops touching it + mMenuController.showMenu(mMenuState, mPipBoundsState.getBounds(), + true /* allowMenuTimeout */, willResizeMenu(), + shouldShowResizeHandle()); + } + mShouldHideMenuAfterFling = mMenuState == MENU_STATE_NONE; + + // Reset the touch state on up before the fling settles + mTouchState.reset(); + if (mEnableStash && shouldStash(vel, getPossiblyMotionBounds())) { + mMotionHelper.stashToEdge(vel.x, vel.y, this::stashEndAction /* endAction */); + } else { + if (mPipBoundsState.isStashed()) { + // Reset stashed state if previously stashed + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_STASH_UNSTASHED); + mPipBoundsState.setStashed(STASH_TYPE_NONE); + } + mMotionHelper.flingToSnapTarget(vel.x, vel.y, + this::flingEndAction /* endAction */); + } + } else if (mTouchState.isDoubleTap() && !mPipBoundsState.isStashed() + && mMenuState != MENU_STATE_FULL) { + // If using pinch to zoom, double-tap functions as resizing between max/min size + if (mPipResizeGestureHandler.isUsingPinchToZoom()) { + final boolean toExpand = mPipBoundsState.getBounds().width() + < mPipBoundsState.getMaxSize().x + && mPipBoundsState.getBounds().height() + < mPipBoundsState.getMaxSize().y; + if (mMenuController.isMenuVisible()) { + mMenuController.hideMenu(ANIM_TYPE_NONE, false /* resize */); + } + + // the size to toggle to after a double tap + int nextSize = PipDoubleTapHelper + .nextSizeSpec(mPipBoundsState, getUserResizeBounds()); + + // actually toggle to the size chosen + if (nextSize == PipDoubleTapHelper.SIZE_SPEC_MAX) { + mPipResizeGestureHandler.setUserResizeBounds(mPipBoundsState.getBounds()); + animateToMaximizedState(null); + } else if (nextSize == PipDoubleTapHelper.SIZE_SPEC_DEFAULT) { + mPipResizeGestureHandler.setUserResizeBounds(mPipBoundsState.getBounds()); + animateToNormalSize(null); + } else { + animateToUnexpandedState(getUserResizeBounds()); + } + } else { + // Expand to fullscreen if this is a double tap + // the PiP should be frozen until the transition ends + setTouchEnabled(false); + mMotionHelper.expandLeavePip(false /* skipAnimation */); + } + } else if (mMenuState != MENU_STATE_FULL) { + if (mPipBoundsState.isStashed()) { + // Unstash immediately if stashed, and don't wait for the double tap timeout + animateToUnStashedState(); + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_STASH_UNSTASHED); + mPipBoundsState.setStashed(STASH_TYPE_NONE); + mTouchState.removeDoubleTapTimeoutCallback(); + } else if (!mTouchState.isWaitingForDoubleTap()) { + // User has stalled long enough for this not to be a drag or a double tap, + // just expand the menu + mMenuController.showMenu(MENU_STATE_FULL, mPipBoundsState.getBounds(), + true /* allowMenuTimeout */, willResizeMenu(), + shouldShowResizeHandle()); + } else { + // Next touch event _may_ be the second tap for the double-tap, schedule a + // fallback runnable to trigger the menu if no touch event occurs before the + // next tap + mTouchState.scheduleDoubleTapTimeoutCallback(); + } + } + cleanUpHighPerfSessionMaybe(); + return true; + } + + private void stashEndAction() { + if (mPipBoundsState.getBounds().left < 0 + && mPipBoundsState.getStashedState() != STASH_TYPE_LEFT) { + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_STASH_LEFT); + mPipBoundsState.setStashed(STASH_TYPE_LEFT); + } else if (mPipBoundsState.getBounds().left >= 0 + && mPipBoundsState.getStashedState() != STASH_TYPE_RIGHT) { + mPipUiEventLogger.log( + PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_STASH_RIGHT); + mPipBoundsState.setStashed(STASH_TYPE_RIGHT); + } + mMenuController.hideMenu(); + } + + private void flingEndAction() { + if (mShouldHideMenuAfterFling) { + // If the menu is not visible, then we can still be showing the activity for the + // dismiss overlay, so just finish it after the animation completes + mMenuController.hideMenu(); + } + } + + private boolean shouldStash(PointF vel, Rect motionBounds) { + final boolean flingToLeft = vel.x < -mStashVelocityThreshold; + final boolean flingToRight = vel.x > mStashVelocityThreshold; + final int offset = motionBounds.width() / 2; + final boolean droppingOnLeft = + motionBounds.left < mPipBoundsState.getDisplayBounds().left - offset; + final boolean droppingOnRight = + motionBounds.right > mPipBoundsState.getDisplayBounds().right + offset; + + // Do not allow stash if the destination edge contains display cutout. We only + // compare the left and right edges since we do not allow stash on top / bottom. + final DisplayCutout displayCutout = + mPipBoundsState.getDisplayLayout().getDisplayCutout(); + if (displayCutout != null) { + if ((flingToLeft || droppingOnLeft) + && !displayCutout.getBoundingRectLeft().isEmpty()) { + return false; + } else if ((flingToRight || droppingOnRight) + && !displayCutout.getBoundingRectRight().isEmpty()) { + return false; + } + } + + // If user flings the PIP window above the minimum velocity, stash PIP. + // Only allow stashing to the edge if PIP wasn't previously stashed on the opposite + // edge. + final boolean stashFromFlingToEdge = + (flingToLeft && mPipBoundsState.getStashedState() != STASH_TYPE_RIGHT) + || (flingToRight && mPipBoundsState.getStashedState() != STASH_TYPE_LEFT); + + // If User releases the PIP window while it's out of the display bounds, put + // PIP into stashed mode. + final boolean stashFromDroppingOnEdge = droppingOnLeft || droppingOnRight; + + return stashFromFlingToEdge || stashFromDroppingOnEdge; + } + } + + /** + * Updates the current movement bounds based on whether the menu is currently visible and + * resized. + */ + private void updateMovementBounds() { + mPipBoundsAlgorithm.getMovementBounds(mPipBoundsState.getBounds(), + mInsetBounds, mPipBoundsState.getMovementBounds(), mIsImeShowing ? mImeHeight : 0); + mMotionHelper.onMovementBoundsChanged(); + } + + private Rect getMovementBounds(Rect curBounds) { + Rect movementBounds = new Rect(); + mPipBoundsAlgorithm.getMovementBounds(curBounds, mInsetBounds, + movementBounds, mIsImeShowing ? mImeHeight : 0); + return movementBounds; + } + + /** + * @return {@code true} if the menu should be resized on tap because app explicitly specifies + * PiP window size that is too small to hold all the actions. + */ + private boolean willResizeMenu() { + if (!mEnableResize) { + return false; + } + final Size estimatedMinMenuSize = mMenuController.getEstimatedMinMenuSize(); + if (estimatedMinMenuSize == null) { + ProtoLog.wtf(WM_SHELL_PICTURE_IN_PICTURE, + "%s: Failed to get estimated menu size", TAG); + return false; + } + final Rect currentBounds = mPipBoundsState.getBounds(); + return currentBounds.width() < estimatedMinMenuSize.getWidth() + || currentBounds.height() < estimatedMinMenuSize.getHeight(); + } + + /** + * Returns the PIP bounds if we're not in the middle of a motion operation, or the current, + * temporary motion bounds otherwise. + */ + Rect getPossiblyMotionBounds() { + return mPipBoundsState.getMotionBoundsState().isInMotion() + ? mPipBoundsState.getMotionBoundsState().getBoundsInMotion() + : mPipBoundsState.getBounds(); + } + + void setOhmOffset(int offset) { + mPipResizeGestureHandler.setOhmOffset(offset); + } + + /** + * Dumps the {@link PipTouchHandler} state. + */ + public void dump(PrintWriter pw, String prefix) { + final String innerPrefix = prefix + " "; + pw.println(prefix + TAG); + pw.println(innerPrefix + "mMenuState=" + mMenuState); + pw.println(innerPrefix + "mIsImeShowing=" + mIsImeShowing); + pw.println(innerPrefix + "mImeHeight=" + mImeHeight); + pw.println(innerPrefix + "mIsShelfShowing=" + mIsShelfShowing); + pw.println(innerPrefix + "mShelfHeight=" + mShelfHeight); + pw.println(innerPrefix + "mSavedSnapFraction=" + mSavedSnapFraction); + pw.println(innerPrefix + "mMovementBoundsExtraOffsets=" + mMovementBoundsExtraOffsets); + mPipBoundsAlgorithm.dump(pw, innerPrefix); + mTouchState.dump(pw, innerPrefix); + if (mPipResizeGestureHandler != null) { + mPipResizeGestureHandler.dump(pw, innerPrefix); + } + } + +} -- cgit v1.2.3-59-g8ed1b