Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 38edb219 authored by Mady Mellor's avatar Mady Mellor
Browse files

Support for a floating task window (behind a sysui flag)

Creates a new package within shell for floating tasks along with a
basic controller and interfaces for sysui and launcher to use to
manage floating tasks.

A single floating task is allowed at a time and they can only be
created if a sysui flag is turned on.

The floating task is backed by a TaskView, floats above all other
content, and can be moved via a handle at the top of the view. The
view sticks along the left and right edges of the screen, can be
stashed (similar to PIP), and can be removed by dragging to a dismiss
target in the middle of the screen (similar to PIP & bubbles).

The tests included trigger the sysui flag on and off to ensure the
flag behavior (i.e. that you can't create one unless the flag is on).

Test: atest FloatingTaskControllerTest
Bug: 237678727
Change-Id: I490c44685825e14166869b2cf7c2994ee0e30ba7
parent 5856ac8f
Loading
Loading
Loading
Loading
+24 −0
Original line number Diff line number Diff line
@@ -297,4 +297,28 @@
      when the pinned stack size is overridden by app via minWidth/minHeight.
    -->
    <dimen name="overridable_minimal_size_pip_resizable_task">48dp</dimen>

    <!-- The size of the drag handle / menu shown along with a floating task. -->
    <dimen name="floating_task_menu_size">32dp</dimen>

    <!-- The size of menu items in the floating task menu. -->
    <dimen name="floating_task_menu_item_size">24dp</dimen>

    <!-- The horizontal margin of menu items in the floating task menu. -->
    <dimen name="floating_task_menu_item_padding">5dp</dimen>

    <!-- The width of visible floating view region when stashed. -->
    <dimen name="floating_task_stash_offset">32dp</dimen>

    <!-- The amount of elevation for a floating task. -->
    <dimen name="floating_task_elevation">8dp</dimen>

    <!-- The amount of padding around the bottom and top of the task. -->
    <dimen name="floating_task_vertical_padding">8dp</dimen>

    <!-- The normal size of the dismiss target. -->
    <dimen name="floating_task_dismiss_circle_size">150dp</dimen>

    <!-- The smaller size of the dismiss target (shrinks when something is in the target). -->
    <dimen name="floating_dismiss_circle_small">120dp</dimen>
</resources>
+2 −0
Original line number Diff line number Diff line
@@ -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();
    }

+42 −0
Original line number Diff line number Diff line
@@ -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<FloatingTasks> provideFloatingTasks(
            Optional<FloatingTasksController> floatingTaskController) {
        return floatingTaskController.map((controller) -> controller.asFloatingTasks());
    }

    @WMSingleton
    @Provides
    static Optional<FloatingTasksController> provideFloatingTasksController(Context context,
            ShellInit shellInit,
            ShellController shellController,
            ShellCommandHandler shellCommandHandler,
            WindowManager windowManager,
            ShellTaskOrganizer organizer,
            TaskViewTransitions taskViewTransitions,
            @ShellMainThread ShellExecutor mainExecutor,
            @ShellBackgroundThread ShellExecutor bgExecutor,
            SyncTransactionQueue syncQueue) {
        if (FloatingTasksController.FLOATING_TASKS_ENABLED) {
            return Optional.of(new FloatingTasksController(context,
                    shellInit,
                    shellController,
                    shellCommandHandler,
                    windowManager,
                    organizer,
                    taskViewTransitions,
                    mainExecutor,
                    bgExecutor,
                    syncQueue));
        } else {
            return Optional.empty();
        }
    }

    //
    // Starting window
    //
+259 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.wm.shell.floating;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.view.MotionEvent;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.dynamicanimation.animation.DynamicAnimation;

import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.DismissView;
import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
import com.android.wm.shell.floating.views.FloatingTaskLayer;
import com.android.wm.shell.floating.views.FloatingTaskView;

import java.util.Objects;

/**
 * Controls a floating dismiss circle that has a 'magnetic' field around it, causing views moved
 * close to the target to be stuck to it unless moved out again.
 */
public class FloatingDismissController {

    /** Velocity required to dismiss the view without dragging it into the dismiss target. */
    private static final float FLING_TO_DISMISS_MIN_VELOCITY = 4000f;
    /**
     * Max velocity that the view can be moving through the target with to stick (i.e. if it's
     * more than this velocity, it will pass through the target.
     */
    private static final float STICK_TO_TARGET_MAX_X_VELOCITY = 2000f;
    /**
     * Percentage of the target width to use to determine if an object flung towards the target
     * should dismiss (e.g. if target is 100px and this is set ot 2f, anything flung within a
     * 200px-wide area around the target will be considered 'near' enough get dismissed).
     */
    private static final float FLING_TO_TARGET_WIDTH_PERCENT = 2f;
    /** Minimum alpha to apply to the view being dismissed when it is in the target. */
    private static final float DISMISS_VIEW_MIN_ALPHA = 0.6f;
    /** Amount to scale down the view being dismissed when it is in the target. */
    private static final float DISMISS_VIEW_SCALE_DOWN_PERCENT = 0.15f;

    private Context mContext;
    private FloatingTasksController mController;
    private FloatingTaskLayer mParent;

    private DismissView mDismissView;
    private ValueAnimator mDismissAnimator;
    private View mViewBeingDismissed;
    private float mDismissSizePercent;
    private float mDismissSize;

    /**
     * The currently magnetized object, which is being dragged and will be attracted to the magnetic
     * dismiss target.
     */
    private MagnetizedObject<View> mMagnetizedObject;
    /**
     * The MagneticTarget instance for our circular dismiss view. This is added to the
     * MagnetizedObject instances for the view being dragged.
     */
    private MagnetizedObject.MagneticTarget mMagneticTarget;
    /** Magnet listener that handles animating and dismissing the view. */
    private MagnetizedObject.MagnetListener mFloatingViewMagnetListener;

    public FloatingDismissController(Context context, FloatingTasksController controller,
            FloatingTaskLayer parent) {
        mContext = context;
        mController = controller;
        mParent = parent;
        updateSizes();
        createAndAddDismissView();

        mDismissAnimator = ValueAnimator.ofFloat(1f, 0f);
        mDismissAnimator.addUpdateListener(animation -> {
            final float value = (float) animation.getAnimatedValue();
            if (mDismissView != null) {
                mDismissView.setPivotX((mDismissView.getRight() - mDismissView.getLeft()) / 2f);
                mDismissView.setPivotY((mDismissView.getBottom() - mDismissView.getTop()) / 2f);
                final float scaleValue = Math.max(value, mDismissSizePercent);
                mDismissView.getCircle().setScaleX(scaleValue);
                mDismissView.getCircle().setScaleY(scaleValue);
            }
            if (mViewBeingDismissed != null) {
                // TODO: alpha doesn't actually apply to taskView currently.
                mViewBeingDismissed.setAlpha(Math.max(value, DISMISS_VIEW_MIN_ALPHA));
                mViewBeingDismissed.setScaleX(Math.max(value, DISMISS_VIEW_SCALE_DOWN_PERCENT));
                mViewBeingDismissed.setScaleY(Math.max(value, DISMISS_VIEW_SCALE_DOWN_PERCENT));
            }
        });

        mFloatingViewMagnetListener = new MagnetizedObject.MagnetListener() {
            @Override
            public void onStuckToTarget(
                    @NonNull MagnetizedObject.MagneticTarget target) {
                animateDismissing(/* dismissing= */ true);
            }

            @Override
            public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target,
                    float velX, float velY, boolean wasFlungOut) {
                animateDismissing(/* dismissing= */ false);
                mParent.onUnstuckFromTarget((FloatingTaskView) mViewBeingDismissed, velX, velY,
                        wasFlungOut);
            }

            @Override
            public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) {
                doDismiss();
            }
        };
    }

    /** Updates all the sizes used and applies them to the {@link DismissView}. */
    public void updateSizes() {
        Resources res = mContext.getResources();
        mDismissSize = res.getDimensionPixelSize(
                R.dimen.floating_task_dismiss_circle_size);
        final float minDismissSize = res.getDimensionPixelSize(
                R.dimen.floating_dismiss_circle_small);
        mDismissSizePercent = minDismissSize / mDismissSize;

        if (mDismissView != null) {
            mDismissView.updateResources();
        }
    }

    /** Prepares the view being dragged to be magnetic. */
    public void setUpMagneticObject(View viewBeingDragged) {
        mViewBeingDismissed = viewBeingDragged;
        mMagnetizedObject = getMagnetizedView(viewBeingDragged);
        mMagnetizedObject.clearAllTargets();
        mMagnetizedObject.addTarget(mMagneticTarget);
        mMagnetizedObject.setMagnetListener(mFloatingViewMagnetListener);
    }

    /** Shows or hides the dismiss target. */
    public void showDismiss(boolean show) {
        if (show) {
            mDismissView.show();
        } else {
            mDismissView.hide();
        }
    }

    /** Passes the MotionEvent to the magnetized object and returns true if it was consumed. */
    public boolean passEventToMagnetizedObject(MotionEvent event) {
        return mMagnetizedObject != null && mMagnetizedObject.maybeConsumeMotionEvent(event);
    }

    private void createAndAddDismissView() {
        if (mDismissView != null) {
            mParent.removeView(mDismissView);
        }
        mDismissView = new DismissView(mContext);
        mDismissView.setTargetSizeResId(R.dimen.floating_task_dismiss_circle_size);
        mDismissView.updateResources();
        mParent.addView(mDismissView);

        final float dismissRadius = mDismissSize;
        // Save the MagneticTarget instance for the newly set up view - we'll add this to the
        // MagnetizedObjects when the dismiss view gets shown.
        mMagneticTarget = new MagnetizedObject.MagneticTarget(
                mDismissView.getCircle(), (int) dismissRadius);
    }

    private MagnetizedObject<View> getMagnetizedView(View v) {
        if (mMagnetizedObject != null
                && Objects.equals(mMagnetizedObject.getUnderlyingObject(), v)) {
            // Same view being dragged, we can reuse the magnetic object.
            return mMagnetizedObject;
        }
        MagnetizedObject<View> magnetizedView = new MagnetizedObject<View>(
                mContext,
                v,
                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y
        ) {
            @Override
            public float getWidth(@NonNull View underlyingObject) {
                return underlyingObject.getWidth();
            }

            @Override
            public float getHeight(@NonNull View underlyingObject) {
                return underlyingObject.getHeight();
            }

            @Override
            public void getLocationOnScreen(@NonNull View underlyingObject,
                    @NonNull int[] loc) {
                loc[0] = (int) underlyingObject.getTranslationX();
                loc[1] = (int) underlyingObject.getTranslationY();
            }
        };
        magnetizedView.setHapticsEnabled(true);
        magnetizedView.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
        magnetizedView.setStickToTargetMaxXVelocity(STICK_TO_TARGET_MAX_X_VELOCITY);
        magnetizedView.setFlingToTargetWidthPercent(FLING_TO_TARGET_WIDTH_PERCENT);
        return magnetizedView;
    }

    /** Animates the dismiss treatment on the view being dismissed. */
    private void animateDismissing(boolean shouldDismiss) {
        if (mViewBeingDismissed == null) {
            return;
        }
        if (shouldDismiss) {
            mDismissAnimator.removeAllListeners();
            mDismissAnimator.start();
        } else {
            mDismissAnimator.removeAllListeners();
            mDismissAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    resetDismissAnimator();
                }
            });
            mDismissAnimator.reverse();
        }
    }

    /** Actually dismisses the view. */
    private void doDismiss() {
        mDismissView.hide();
        mController.removeTask();
        resetDismissAnimator();
        mViewBeingDismissed = null;
    }

    private void resetDismissAnimator() {
        mDismissAnimator.removeAllListeners();
        mDismissAnimator.cancel();
        if (mDismissView != null) {
            mDismissView.cancelAnimators();
            mDismissView.getCircle().setScaleX(1f);
            mDismissView.getCircle().setScaleY(1f);
        }
    }
}
+41 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}
Loading