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

Commit cf9132e0 authored by Jeremy Sim's avatar Jeremy Sim
Browse files

Fix app pairs launch from in-app Taskbar

This CL adds a handler function in SplitAnimationController that manages the complicated logic flow for launching an app pair when already inside of an app.

To give an idea of the complicated logic:
If the user tapped on an app pair while already in an app pair, there are 4 general cases:
  a) Clicked app pair A|B, but both apps are already running on screen
  b) App A is already on-screen, but App B isn't
  c) App B is on-screen, but App A isn't
  d) Neither is on-screen

If the user tapped an app pair while inside a single app, there are 3 cases:
   a) The on-screen app is App A of the app pair
   b) The on-screen app is App B of the app pair
   c) It is neither

For each case, we call a different animation and launch the app pair in a different way.

When merged, this patch will fix all animation glitches that are currently happening in these situations, and get us 90% of the way to having the ideal animation in all cases. There are still a few complicated cases that need a polished animation (like when you launch app pairs with custom ratios), which will be implemented in a following patch soon (I thought this CL was big enough already as is).

Bug: 316485863
Fixes: 315190686
Test: Manual testing of all the different launch combinations
Flag: ACONFIG com.android.wm.shell.enable_app_pairs TEAMFOOD
Change-Id: I5c0e03512bb706360c575d833cac6ed02a5de936
parent 6474a3b4
Loading
Loading
Loading
Loading
+40 −6
Original line number Diff line number Diff line
@@ -1063,7 +1063,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
                        Toast.LENGTH_SHORT).show();
            } else {
                // Else launch the selected app pair
                launchFromTaskbarPreservingSplitIfVisible(recents, view, fi.contents);
                launchFromTaskbar(recents, view, fi.contents);
                mControllers.uiController.onTaskbarIconLaunched(fi);
                mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
            }
@@ -1097,8 +1097,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
                            getSystemService(LauncherApps.class)
                                    .startShortcut(packageName, id, null, null, info.user);
                        } else {
                            launchFromTaskbarPreservingSplitIfVisible(
                                    recents, view, Collections.singletonList(info));
                            launchFromTaskbar(recents, view, Collections.singletonList(info));
                        }

                    } catch (NullPointerException
@@ -1136,8 +1135,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
                // If we are selecting a second app for split, launch the split tasks
                taskbarUIController.triggerSecondAppForSplit(info, info.intent, view);
            } else {
                launchFromTaskbarPreservingSplitIfVisible(
                        recents, view, Collections.singletonList(info));
                launchFromTaskbar(recents, view, Collections.singletonList(info));
            }
            mControllers.uiController.onTaskbarIconLaunched(info);
            mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true);
@@ -1152,13 +1150,49 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
        }
    }

    /**
     * Runs when the user taps a Taskbar icon in TaskbarActivityContext (Overview or inside an app),
     * and calls the appropriate method to animate and launch.
     */
    private void launchFromTaskbar(@Nullable RecentsView recents, @Nullable View launchingIconView,
            List<? extends ItemInfo> itemInfos) {
        if (isInApp()) {
            launchFromInAppTaskbar(recents, launchingIconView, itemInfos);
        } else {
            launchFromOverviewTaskbar(recents, launchingIconView, itemInfos);
        }
    }

    /**
     * Runs when the user taps a Taskbar icon while inside an app.
     */
    private void launchFromInAppTaskbar(@Nullable RecentsView recents,
            @Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
        if (recents == null) {
            return;
        }

        boolean tappedAppPair = itemInfos.size() == 2;

        if (tappedAppPair) {
            // If the icon is an app pair, the logic gets a bit complicated because we play
            // different animations depending on which app (or app pair) is currently running on
            // screen, so delegate logic to appPairsController.
            recents.getSplitSelectController().getAppPairsController()
                    .handleAppPairLaunchInApp((AppPairIcon) launchingIconView, itemInfos);
        } else {
            // Tapped a single app, nothing complicated here.
            startItemInfoActivity(itemInfos.get(0));
        }
    }

    /**
     * Run when the user taps a Taskbar icon while in Overview. If the tapped app is currently
     * visible to the user in Overview, or is part of a visible split pair, we expand the TaskView
     * as if the user tapped on it (preserving the split pair). Otherwise, launch it normally
     * (potentially breaking a split pair).
     */
    private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents,
    private void launchFromOverviewTaskbar(@Nullable RecentsView recents,
            @Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
        if (recents == null) {
            return;
+186 −2
Original line number Diff line number Diff line
@@ -17,17 +17,22 @@

package com.android.quickstep.util;

import static android.app.ActivityTaskManager.INVALID_TASK_ID;

import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_APP_PAIR_LAUNCH;
import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT;
import static com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT;
import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_BOTTOM_OR_RIGHT;
import static com.android.wm.shell.common.split.SplitScreenConstants.SPLIT_POSITION_TOP_OR_LEFT;
import static com.android.wm.shell.common.split.SplitScreenConstants.isPersistentSnapPosition;

import android.app.ActivityTaskManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.LauncherApps;
import android.util.Log;
import android.util.Pair;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
@@ -39,16 +44,23 @@ import com.android.launcher3.R;
import com.android.launcher3.accessibility.LauncherAccessibilityDelegate;
import com.android.launcher3.apppairs.AppPairIcon;
import com.android.launcher3.icons.IconCache;
import com.android.launcher3.logging.InstanceId;
import com.android.launcher3.logging.StatsLogManager;
import com.android.launcher3.model.data.FolderInfo;
import com.android.launcher3.model.data.ItemInfo;
import com.android.launcher3.model.data.WorkspaceItemInfo;
import com.android.launcher3.taskbar.TaskbarActivityContext;
import com.android.launcher3.util.ComponentKey;
import com.android.launcher3.util.SplitConfigurationOptions.StagePosition;
import com.android.quickstep.SystemUiProxy;
import com.android.quickstep.TopTaskTracker;
import com.android.quickstep.views.GroupedTaskView;
import com.android.quickstep.views.TaskView;
import com.android.systemui.shared.recents.model.Task;
import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition;

import java.util.Arrays;
import java.util.List;

/**
 * Controller class that handles app pair interactions: saving, modifying, deleting, etc.
@@ -150,7 +162,7 @@ public class AppPairsController {
                        task1Id = foundTask1.key.id;
                        task1Intent = null;
                    } else {
                        task1Id = ActivityTaskManager.INVALID_TASK_ID;
                        task1Id = INVALID_TASK_ID;
                        task1Intent = app1.intent;
                    }

@@ -176,6 +188,170 @@ public class AppPairsController {
        );
    }

    /**
     * Handles the complicated logic for how to animate an app pair entrance when already inside an
     * app or app pair.
     *
     * If the user tapped on an app pair while already in an app pair, there are 4 general cases:
     *   a) Clicked app pair A|B, but both apps are already running on screen.
     *   b) App A is already on-screen, but App B isn't.
     *   c) App B is on-screen, but App A isn't.
     *   d) Neither is on-screen.
     *
     * If the user tapped an app pair while inside a single app, there are 3 cases:
     *   a) The on-screen app is App A of the app pair.
     *   b) The on-screen app is App B of the app pair.
     *   c) It is neither.
     *
     * For each case, we call the appropriate animation and split launch type.
     */
    public void handleAppPairLaunchInApp(AppPairIcon launchingIconView,
            List<? extends ItemInfo> itemInfos) {
        TaskbarActivityContext context = (TaskbarActivityContext) launchingIconView.getContext();
        List<ComponentKey> componentKeys =
                itemInfos.stream().map(ItemInfo::getComponentKey).toList();

        // Use TopTaskTracker to find the currently running app (or apps)
        TopTaskTracker topTaskTracker = getTopTaskTracker(context);

        // getRunningSplitTasksIds() will return a pair of ids if we are currently running a
        // split pair, or an empty array with zero length if we are running a single app.
        int[] runningSplitTasks = topTaskTracker.getRunningSplitTaskIds();
        if (runningSplitTasks != null && runningSplitTasks.length == 2) {
            // Tapped an app pair while in an app pair
            int runningTaskId1 = runningSplitTasks[0];
            int runningTaskId2 = runningSplitTasks[1];

            mSplitSelectStateController.findLastActiveTasksAndRunCallback(
                    componentKeys,
                    false /* findExactPairMatch */,
                    foundTasks -> {
                        // If our clicked app pair has already-running Tasks, we grab the
                        // taskIds here so we can see if those ids are already on-screen now
                        List<Integer> lastActiveTasksOfAppPair =
                                Arrays.stream(foundTasks).map((Task task) -> {
                                    if (task != null) {
                                        return task.getKey().getId();
                                    } else {
                                        return INVALID_TASK_ID;
                                    }
                                }).toList();

                        if (lastActiveTasksOfAppPair.contains(runningTaskId1)
                                && lastActiveTasksOfAppPair.contains(runningTaskId2)) {
                            // App A and App B are already on-screen, so do nothing.
                        } else if (!lastActiveTasksOfAppPair.contains(runningTaskId1)
                                && !lastActiveTasksOfAppPair.contains(runningTaskId2)) {
                            // Neither A nor B are on screen, so just launch a new app pair
                            // normally.
                            launchAppPair(launchingIconView);
                        } else {
                            // Exactly one app (A or B) is on-screen, so we have to launch the other
                            // on the appropriate side.
                            ItemInfo app1 = itemInfos.get(0);
                            ItemInfo app2 = itemInfos.get(1);
                            int task1 = lastActiveTasksOfAppPair.get(0);
                            int task2 = lastActiveTasksOfAppPair.get(1);

                            // If task1 is one of the running on-screen tasks, we launch app2.
                            // If not, task2 must be the running task, and we launch app1.
                            ItemInfo appToLaunch =
                                    task1 == runningTaskId1 || task1 == runningTaskId2
                                            ? app2
                                            : app1;
                            // If the on-screen task is on the bottom/right position, we launch to
                            // the top/left. If not, we launch to the bottom/right.
                            @StagePosition int sideToLaunch =
                                    task1 == runningTaskId2 || task2 == runningTaskId2
                                            ? STAGE_POSITION_TOP_OR_LEFT
                                            : STAGE_POSITION_BOTTOM_OR_RIGHT;

                            launchToSide(context, launchingIconView.getInfo(), appToLaunch,
                                    sideToLaunch);
                        }
                    }
            );
        } else {
            // Tapped an app pair while in a single app
            int runningTaskId = topTaskTracker
                    .getCachedTopTask(false /* filterOnlyVisibleRecents */).getTaskId();

            mSplitSelectStateController.findLastActiveTasksAndRunCallback(
                    componentKeys,
                    false /* findExactPairMatch */,
                    foundTasks -> {
                        Task foundTask1 = foundTasks[0];
                        Task foundTask2 = foundTasks[1];
                        boolean task1IsOnScreen =
                                foundTask1 != null && foundTask1.getKey().getId() == runningTaskId;
                        boolean task2IsOnScreen =
                                foundTask2 != null && foundTask2.getKey().getId() == runningTaskId;

                        if (!task1IsOnScreen && !task2IsOnScreen) {
                            // Neither App A nor App B are on-screen, launch the app pair normally.
                            launchAppPair(launchingIconView);
                        } else {
                            // Either A or B is on-screen, so launch the other on the appropriate
                            // side.
                            ItemInfo app1 = itemInfos.get(0);
                            ItemInfo app2 = itemInfos.get(1);
                            // If task1 is the running on-screen task, we launch app2 on the
                            // bottom/right. If task2 is on-screen, launch app1 on the top/left.
                            ItemInfo appToLaunch = task1IsOnScreen ? app2 : app1;
                            @StagePosition int sideToLaunch = task1IsOnScreen
                                    ? STAGE_POSITION_BOTTOM_OR_RIGHT
                                    : STAGE_POSITION_TOP_OR_LEFT;

                            launchToSide(context, launchingIconView.getInfo(), appToLaunch,
                                    sideToLaunch);
                        }
                }
            );
        }
    }

    /**
     * Executes a split launch by launching an app to the side of an existing app.
     * @param context The TaskbarActivityContext that we are launching the app pair from.
     * @param launchingItemInfo The itemInfo of the icon that was tapped.
     * @param app The app that will launch to the side of the existing running app (not necessarily
     *  the same as the previous parameter; e.g. we tap an app pair but launch an app).
     * @param side A @StagePosition, either STAGE_POSITION_TOP_OR_LEFT or
     *  STAGE_POSITION_BOTTOM_OR_RIGHT.
     */
    @VisibleForTesting
    public void launchToSide(
            TaskbarActivityContext context,
            ItemInfo launchingItemInfo,
            ItemInfo app,
            @StagePosition int side
    ) {
        LauncherApps launcherApps = context.getSystemService(LauncherApps.class);

        // Set up to log app pair launch event
        Pair<com.android.internal.logging.InstanceId, InstanceId> instanceIds =
                LogUtils.getShellShareableInstanceId();
        context.getStatsLogManager()
                .logger()
                .withItemInfo(launchingItemInfo)
                .withInstanceId(instanceIds.second)
                .log(LAUNCHER_APP_PAIR_LAUNCH);

        SystemUiProxy.INSTANCE.get(context)
                .startIntent(
                        launcherApps.getMainActivityLaunchIntent(
                                app.getIntent().getComponent(),
                                null,
                                app.user
                        ),
                        app.user.getIdentifier(),
                        new Intent(),
                        side,
                        null,
                        instanceIds.first
                );
    }

    /**
     * App pair members have a "rank" attribute that contains information about the split position
     * and ratio. We implement this by splitting the int in half (e.g. 16 bits each), then use one
@@ -209,4 +385,12 @@ public class AppPairsController {
    public String getDefaultTitle(CharSequence app1, CharSequence app2) {
        return mContext.getString(R.string.app_pair_default_title, app1, app2);
    }

    /**
     * Gets the TopTaskTracker, which is a cached record of the top running Task.
     */
    @VisibleForTesting
    public TopTaskTracker getTopTaskTracker(Context context) {
        return TopTaskTracker.INSTANCE.get(context);
    }
}
+97 −8
Original line number Diff line number Diff line
@@ -391,14 +391,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC
                "trying to launch an app pair icon, but encountered an unexpected null"
            }

            // If launching an app pair from Taskbar inside of an app context, use fade-in animation
            // TODO (b/316485863): Replace with desired app pair launch animation
            if (launchingIconView.context is TaskbarActivityContext) {
                composeFadeInSplitLaunchAnimator(
                    initialTaskId, secondTaskId, info, t, finishCallback)
                return
            }

            composeIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback)
        } else {
            // Fallback case: simple fade-in animation
@@ -483,6 +475,10 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC
     * We want to animate the Root (grandparent) so that it affects both apps and the divider.
     * To do this, we find one of the nodes with WINDOWING_MODE_MULTI_WINDOW (one of the
     * left-side ones, for simplicity) and traverse the tree until we find the grandparent.
     *
     * This function is only called when we are animating the app pair in from scratch. It is NOT
     * called when we are animating in from an existing visible TaskView tile or an app that is
     * already on screen.
     */
    @VisibleForTesting
    fun composeIconSplitLaunchAnimator(
@@ -491,6 +487,14 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC
        t: Transaction,
        finishCallback: Runnable
    ) {
        // If launching an app pair from Taskbar inside of an app context (no access to Launcher),
        // use the scale-up animation
        if (launchingIconView.context is TaskbarActivityContext) {
            composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback)
            return
        }

        // Else we are in Launcher and can launch with the full icon stretch-and-split animation.
        val launcher = Launcher.getLauncher(launchingIconView.context)
        val dp = launcher.deviceProfile

@@ -649,6 +653,91 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC
        launchAnimation.start()
    }

    /**
     * This is a scale-up-and-fade-in animation (34% to 100%) for launching an app in Overview when
     * there is no visible associated tile to expand from.
     */
    @VisibleForTesting
    fun composeScaleUpLaunchAnimation(
        transitionInfo: TransitionInfo,
        t: Transaction,
        finishCallback: Runnable
    ) {
        val launchAnimation = AnimatorSet()
        val progressUpdater = ValueAnimator.ofFloat(0f, 1f)
        progressUpdater.setDuration(QuickstepTransitionManager.APP_LAUNCH_DURATION)
        progressUpdater.interpolator = Interpolators.EMPHASIZED

        var rootCandidate: Change? = null

        for (change in transitionInfo.changes) {
            val taskInfo: RunningTaskInfo = change.taskInfo ?: continue

            // TODO (b/316490565): Replace this logic when SplitBounds is available to
            //  startAnimation() and we can know the precise taskIds of launching tasks.
            // Find a change that has WINDOWING_MODE_MULTI_WINDOW.
            if (
                taskInfo.windowingMode == WINDOWING_MODE_MULTI_WINDOW &&
                    (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT)
            ) {
                // Found one!
                rootCandidate = change
                break
            }
        }

        // If we could not find a proper root candidate, something went wrong.
        check(rootCandidate != null) { "Could not find a split root candidate" }

        // Recurse up the tree until parent is null, then we've found our root.
        var parentToken: WindowContainerToken? = rootCandidate.parent
        while (parentToken != null) {
            rootCandidate = transitionInfo.getChange(parentToken) ?: break
            parentToken = rootCandidate.parent
        }

        // Make sure nothing weird happened, like getChange() returning null.
        check(rootCandidate != null) { "Failed to find a root leash" }

        // Starting position is a 34% size tile centered in the middle of the screen.
        // Ending position is the full device screen.
        val screenBounds = rootCandidate.endAbsBounds
        val startingScale = 0.34f
        val startX =
            screenBounds.left +
                ((screenBounds.right - screenBounds.left) * ((1 - startingScale) / 2f))
        val startY =
            screenBounds.top +
                ((screenBounds.bottom - screenBounds.top) * ((1 - startingScale) / 2f))
        val endX = screenBounds.left
        val endY = screenBounds.top

        progressUpdater.addUpdateListener { valueAnimator: ValueAnimator ->
            val progress = valueAnimator.animatedFraction

            val x = startX + ((endX - startX) * progress)
            val y = startY + ((endY - startY) * progress)
            val scale = startingScale + ((1 - startingScale) * progress)

            t.setPosition(rootCandidate.leash, x, y)
            t.setScale(rootCandidate.leash, scale, scale)
            t.setAlpha(rootCandidate.leash, progress)
            t.apply()
        }

        // When animation ends,  run finishCallback
        progressUpdater.addListener(
            object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    finishCallback.run()
                }
            }
        )

        launchAnimation.play(progressUpdater)
        launchAnimation.start()
    }

    /**
     * If we are launching split screen without any special animation from a starting View, we
     * simply fade in the starting apps and fade out launcher.
+262 −0

File changed.

Preview size limit exceeded, changes collapsed.

+2 −3
Original line number Diff line number Diff line
@@ -281,7 +281,7 @@ class SplitAnimationControllerTest {
        whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext)
        doNothing()
            .whenever(spySplitAnimationController)
            .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
            .composeScaleUpLaunchAnimation(any(), any(), any())

        spySplitAnimationController.playSplitLaunchAnimation(
            null /* launchingTaskView */,
@@ -298,8 +298,7 @@ class SplitAnimationControllerTest {
            {} /* finishCallback */
        )

        verify(spySplitAnimationController)
            .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any())
        verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any())
    }

    @Test