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

Commit 153f7afc authored by Eric Lin's avatar Eric Lin
Browse files

Fix bubble task relaunch into split screen transition.

When a bubbled task was relaunched into split screen, the system would
enter a broken state due to incorrect transition handling. The issue
occurred because the bubble transition logic didn't properly detect when
a bubble task should move to split screen, causing the system to
mistakenly treat it as a new bubble creation.

The root cause was in BubbleController#shouldBeAppBubble, which
continued to return true for tasks transitioning to split screen. This
caused DefaultMixedHandler to incorrectly start a "bubble-enter"
transition, recreating the bubble after it was initially removed for the
split transition.

This change adds a check in shouldBeAppBubble to detect when a task is
moving to split screen using the isBubbleToSplit utility function,
returning false in such cases to prevent the erroneous bubble-enter
transition. Additionally, BubbleTaskView is updated to accept a
SplitScreenController dependency and its cleanup method now calls
unregisterTask instead of removeTask for tasks transitioning to split
screen, ensuring the bubble controller properly detaches without
destroying the task so split screen can take over management.

Bug: 432604687
Flag: EXEMPT BUGFIX
Test: atest WMShellRobolectricTests:BubbleControllerTest
Test: atest WMShellRobolectricTests:BubbleTaskViewTest
Test: atest WMShellMultivalentTestsOnDevice:BubbleControllerTest
Test: atest WMShellMultivalentTestsOnDevice:BubbleTaskViewTest
Change-Id: I5ac09b95f027eb82dd82df2ce3fcef7aaa91093c
parent 2624b192
Loading
Loading
Loading
Loading
+86 −12
Original line number Diff line number Diff line
@@ -51,6 +51,7 @@ import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.android.internal.logging.testing.UiEventLoggerFake
import com.android.internal.protolog.ProtoLog
import com.android.internal.statusbar.IStatusBarService
import com.android.window.flags.Flags.FLAG_ROOT_TASK_FOR_BUBBLE
import com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_BAR
import com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_BAR_TO_FLOATING_TRANSITION
import com.android.wm.shell.Flags.FLAG_ENABLE_CREATE_ANY_BUBBLE
@@ -76,6 +77,7 @@ import com.android.wm.shell.draganddrop.DragAndDropController
import com.android.wm.shell.shared.TransactionPool
import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper
import com.android.wm.shell.shared.bubbles.DeviceConfig
import com.android.wm.shell.splitscreen.SplitScreenController
import com.android.wm.shell.sysui.ShellCommandHandler
import com.android.wm.shell.sysui.ShellController
import com.android.wm.shell.sysui.ShellInit
@@ -88,6 +90,8 @@ import com.android.wm.shell.transition.Transitions.TransitionHandler
import com.android.wm.shell.unfold.ShellUnfoldProgressProvider
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.MoreExecutors.directExecutor
import java.util.Optional
import java.util.concurrent.Executor
import org.junit.After
import org.junit.Assume.assumeTrue
import org.junit.Before
@@ -102,11 +106,10 @@ import org.mockito.kotlin.eq
import org.mockito.kotlin.isA
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters
import java.util.Optional
import java.util.concurrent.Executor

/** Tests for [BubbleController].
 *
@@ -123,14 +126,16 @@ class BubbleControllerTest(flags: FlagsParameterization) {

    private val context = ApplicationProvider.getApplicationContext<Context>()
    private val uiEventLoggerFake = UiEventLoggerFake()
    private val bubbleAppInfoProvider = FakeBubbleAppInfoProvider()
    private val unfoldProgressProvider = FakeShellUnfoldProgressProvider()
    private val displayImeController = mock<DisplayImeController>()
    private val displayInsetsController = mock<DisplayInsetsController>()
    private val userManager = mock<UserManager>()
    private val splitScreenController = mock<SplitScreenController>()
    private val taskStackListener = mock<TaskStackListenerImpl>()
    private val transitions = mock<Transitions>()
    private val taskViewTransitions = mock<TaskViewTransitions>()
    private val bubbleAppInfoProvider = FakeBubbleAppInfoProvider()
    private val unfoldProgressProvider = FakeShellUnfoldProgressProvider()
    private val userManager = mock<UserManager>()
    private val windowManager = mock<WindowManager>()

    private lateinit var bubbleController: BubbleController
    private lateinit var bubblePositioner: BubblePositioner
@@ -142,7 +147,6 @@ class BubbleControllerTest(flags: FlagsParameterization) {
    private lateinit var displayController: DisplayController
    private lateinit var imeListener: ImeListener
    private lateinit var bubbleTransitions: BubbleTransitions
    private lateinit var shellTaskOrganizer: ShellTaskOrganizer

    private var isStayAwakeOnFold = false

@@ -175,7 +179,7 @@ class BubbleControllerTest(flags: FlagsParameterization) {
        val realWindowManager = context.getSystemService<WindowManager>()!!
        val realDefaultDisplay = realWindowManager.defaultDisplay
        // Tests don't have permission to add our window to windowManager, so we mock it :(
        val windowManager = mock<WindowManager> {
        windowManager.stub {
            // But we do want the metrics from the real one
            on { currentWindowMetrics } doReturn realWindowManager.currentWindowMetrics
            on { defaultDisplay } doReturn realDefaultDisplay
@@ -196,7 +200,7 @@ class BubbleControllerTest(flags: FlagsParameterization) {
                bgExecutor,
            )

        shellTaskOrganizer =
        val shellTaskOrganizer =
            ShellTaskOrganizer(
                mock<ShellInit>(),
                ShellCommandHandler(),
@@ -222,14 +226,12 @@ class BubbleControllerTest(flags: FlagsParameterization) {
            createBubbleController(
                bubbleData,
                windowManager,
                shellTaskOrganizer,
                bubbleLogger,
                bubblePositioner,
                mainExecutor,
                bgExecutor,
            )
        bubbleController.asBubbles().setSysuiProxy(mock<SysuiProxy>())
        // Flush so that proxy gets set
        mainExecutor.flushAll()

        val insetsChangedListenerCaptor = argumentCaptor<ImeListener>()
        verify(displayInsetsController)
@@ -458,6 +460,50 @@ class BubbleControllerTest(flags: FlagsParameterization) {
        assertThat(bubbleController.hasStableBubbleForTask(777)).isFalse()
    }

    @EnableFlags(FLAG_ROOT_TASK_FOR_BUBBLE)
    @Test
    fun shouldBeAppBubble_parentTaskMatchesBubbleRootTask_returnsTrue() {
        val bubbleController = createBubbleControllerWithRootTask(bubbleRootTaskId = 777)
        val taskInfo = ActivityManager.RunningTaskInfo().apply { parentTaskId = 777 }

        assertThat(bubbleController.shouldBeAppBubble(taskInfo)).isTrue()
    }

    @EnableFlags(FLAG_ROOT_TASK_FOR_BUBBLE)
    @Test
    fun shouldBeAppBubble_parentTaskDoesNotMatchesBubbleRootTask_returnsFalse() {
        val bubbleController = createBubbleControllerWithRootTask(bubbleRootTaskId = 123)
        val taskInfo = ActivityManager.RunningTaskInfo().apply { parentTaskId = 456 }

        assertThat(bubbleController.shouldBeAppBubble(taskInfo)).isFalse()
    }

    @DisableFlags(FLAG_ROOT_TASK_FOR_BUBBLE)
    @Test
    fun shouldBeAppBubble_taskIsSplitting_returnsFalse() {
        val sideStageRootTask = 5
        splitScreenController.stub {
            on { isTaskRootOrStageRoot(sideStageRootTask) } doReturn true
        }
        val taskInfo = ActivityManager.RunningTaskInfo().apply {
            // Task is running in split-screen mode.
            parentTaskId = sideStageRootTask
            // Even though the task was previously marked as an app bubble,
            // it should not be considered a bubble when in split-screen mode.
            isAppBubble = true
        }

        assertThat(bubbleController.shouldBeAppBubble(taskInfo)).isFalse()
    }

    @DisableFlags(FLAG_ROOT_TASK_FOR_BUBBLE)
    @Test
    fun shouldBeAppBubble_isAppBubbleNotSplitting_returnsTrue() {
        val taskInfo = ActivityManager.RunningTaskInfo().apply { isAppBubble = true }

        assertThat(bubbleController.shouldBeAppBubble(taskInfo)).isTrue()
    }

    @EnableFlags(FLAG_ENABLE_BUBBLE_BAR)
    @Test
    fun expandStackAndSelectBubbleForExistingTransition_reusesExistingBubble() {
@@ -839,6 +885,7 @@ class BubbleControllerTest(flags: FlagsParameterization) {
    private fun createBubbleController(
        bubbleData: BubbleData,
        windowManager: WindowManager,
        shellTaskOrganizer: ShellTaskOrganizer,
        bubbleLogger: BubbleLogger,
        bubblePositioner: BubblePositioner,
        mainExecutor: TestShellExecutor,
@@ -901,13 +948,40 @@ class BubbleControllerTest(flags: FlagsParameterization) {
                resizeChecker,
                HomeIntentProvider(context),
                bubbleAppInfoProvider,
                { Optional.empty() },
                { Optional.of(splitScreenController) },
                Optional.of(unfoldProgressProvider),
                { isStayAwakeOnFold },
            )
        bubbleController.setInflateSynchronously(true)
        bubbleController.onInit()

        bubbleController.asBubbles().setSysuiProxy(mock<SysuiProxy>())
        // Flush so that proxy gets set
        mainExecutor.flushAll()

        return bubbleController
    }

    private fun createBubbleControllerWithRootTask(bubbleRootTaskId: Int): BubbleController {
        val shellTaskOrganizer = mock<ShellTaskOrganizer>()
        val bubbleController = createBubbleController(
            bubbleData,
            windowManager,
            shellTaskOrganizer,
            bubbleLogger,
            bubblePositioner,
            mainExecutor,
            bgExecutor,
        )

        val rootTaskListener = argumentCaptor<ShellTaskOrganizer.TaskListener>().let { captor ->
            verify(shellTaskOrganizer).createRootTask(any(), captor.capture())
            captor.lastValue
        }

        val bubbleRootTask = ActivityManager.RunningTaskInfo().apply { taskId = bubbleRootTaskId }
        rootTaskListener.onTaskAppeared(bubbleRootTask, null /* leash */)

        return bubbleController
    }

+30 −12
Original line number Diff line number Diff line
@@ -26,9 +26,11 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SmallTest
import com.android.wm.shell.Flags.FLAG_ENABLE_CREATE_ANY_BUBBLE
import com.android.wm.shell.shared.bubbles.BubbleAnythingFlagHelper
import com.android.wm.shell.splitscreen.SplitScreenController
import com.android.wm.shell.taskview.TaskView
import com.google.common.truth.Truth.assertThat
import com.google.common.util.concurrent.MoreExecutors.directExecutor
import java.util.Optional
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -49,8 +51,17 @@ class BubbleTaskViewTest(flags: FlagsParameterization) {

    private val context = ApplicationProvider.getApplicationContext<Context>()
    private val componentName = ComponentName(context, "TestClass")
    private val taskView = mock<TaskView>()
    private val bubbleTaskView = BubbleTaskView(taskView, directExecutor())
    private val runningTaskInfo = ActivityManager.RunningTaskInfo()
    private val splitScreenController = mock<SplitScreenController>()
    private val taskView = mock<TaskView> {
        on { taskInfo } doReturn runningTaskInfo
    }
    private val bubbleTaskView =
        BubbleTaskView(
            taskView,
            executor = directExecutor(),
            splitScreenController = { Optional.of(splitScreenController) },
        )

    @Test
    fun onTaskCreated_updatesState() {
@@ -80,38 +91,45 @@ class BubbleTaskViewTest(flags: FlagsParameterization) {
    }

    @Test
    fun cleanup_invalidTaskId_removesTask() {
    fun cleanup_noTaskCreated_removesTask() {
        bubbleTaskView.cleanup()

        verify(taskView, never()).unregisterTask()
        verify(taskView).removeTask()
    }

    @Test
    fun cleanup_validTaskId_removesTask() {
    fun cleanup_regularBubbleTask_removesTask() {
        bubbleTaskView.listener.onTaskCreated(123 /* taskId */, componentName)

        bubbleTaskView.cleanup()

        verify(taskView, never()).unregisterTask()
        verify(taskView).removeTask()
    }

    @Test
    fun cleanup_noneFullscreenTask_removesTask() {
    fun cleanup_taskTransitioningToSplitScreen_unregistersTask() {
        val sideStageRootTask = 5
        splitScreenController.stub {
            on { isTaskRootOrStageRoot(sideStageRootTask) } doReturn true
        }
        runningTaskInfo.apply {
            parentTaskId = sideStageRootTask // Task is running in split-screen mode.
        }
        bubbleTaskView.listener.onTaskCreated(123 /* taskId */, componentName)

        bubbleTaskView.cleanup()

        verify(taskView, never()).unregisterTask()
        verify(taskView).removeTask()
        verify(taskView).unregisterTask()
        verify(taskView, never()).removeTask()
    }

    @Test
    fun cleanup_fullscreenTask_removesOrUnregistersTask() {
        val fullScreenTaskInfo = ActivityManager.RunningTaskInfo().apply {
    fun cleanup_taskTransitioningToFullscreen_removesOrUnregistersTask() {
        runningTaskInfo.apply {
            configuration.windowConfiguration.windowingMode = WINDOWING_MODE_FULLSCREEN
        }
        taskView.stub {
            on { taskInfo } doReturn fullScreenTaskInfo
        }
        bubbleTaskView.listener.onTaskCreated(123 /* taskId */, componentName)

        bubbleTaskView.cleanup()
+15 −1
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@ import static com.android.wm.shell.bubbles.Bubbles.DISMISS_NO_LONGER_BUBBLE;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_PACKAGE_REMOVED;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_SHORTCUT_REMOVED;
import static com.android.wm.shell.bubbles.Bubbles.DISMISS_USER_CHANGED;
import static com.android.wm.shell.bubbles.util.BubbleUtils.isBubbleToSplit;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_BUBBLES_NOISY;
import static com.android.wm.shell.transition.Transitions.TRANSIT_BUBBLE_CONVERT_FLOATING_TO_BAR;
@@ -422,7 +423,7 @@ public class BubbleController implements ConfigurationChangeListener,
                        context, organizer, mTaskViewController, syncQueue);
                TaskView taskView = new TaskView(context, mTaskViewController,
                        taskViewTaskController);
                return new BubbleTaskView(taskView, mainExecutor);
                return new BubbleTaskView(taskView, mainExecutor, splitScreenController);
            }
        };
        mExpandedViewManager = BubbleExpandedViewManager.fromBubbleController(this);
@@ -1470,6 +1471,19 @@ public class BubbleController implements ConfigurationChangeListener,
            return mAppBubbleRootTaskInfo != null
                    && taskInfo.parentTaskId == mAppBubbleRootTaskInfo.taskId;
        }

        // Skip treating the task as an app bubble if it's transitioning from bubble to split.
        // In BubblesTransitionObserver#removeBubbleIfLaunchingToSplit, a WCT is applied to set
        // LaunchNextToBubble=false. Then TaskViewTaskController#notifyTaskRemovalStarted is called,
        // which triggers this check. However, the isAppBubble flag is only updated during the next
        // Task#fillTaskInfo by the WM core, so the flag we are currently processing is still true.
        // Later, TaskViewTransitions#onExternalDone unblocks the animation. Without this check,
        // DefaultMixedHandler could misinterpret the OPEN change as a bubble-enter transition,
        // incorrectly re-creating the bubble instead of completing the split-screen transition.
        if (isBubbleToSplit(taskInfo, mSplitScreenController)) {
            return false;
        }

        return taskInfo.isAppBubble;
    }

+11 −2
Original line number Diff line number Diff line
@@ -21,7 +21,11 @@ import android.app.ActivityTaskManager.INVALID_TASK_ID
import android.content.ComponentName
import androidx.annotation.VisibleForTesting
import com.android.wm.shell.bubbles.util.BubbleUtils.isBubbleToFullscreen
import com.android.wm.shell.bubbles.util.BubbleUtils.isBubbleToSplit
import com.android.wm.shell.splitscreen.SplitScreenController
import com.android.wm.shell.taskview.TaskView
import dagger.Lazy
import java.util.Optional
import java.util.concurrent.Executor

/**
@@ -29,7 +33,12 @@ import java.util.concurrent.Executor
 *
 * [delegateListener] allows callers to change listeners after a task has been created.
 */
class BubbleTaskView(val taskView: TaskView, executor: Executor) {
class BubbleTaskView @JvmOverloads constructor(
    val taskView: TaskView,
    executor: Executor,
    private val splitScreenController: Lazy<Optional<SplitScreenController>> =
        Lazy { Optional.empty() },
) {

    /** Whether the task is already created. */
    var isCreated = false
@@ -109,7 +118,7 @@ class BubbleTaskView(val taskView: TaskView, executor: Executor) {
     */
    fun cleanup() {
        val task = taskView.taskInfo
        if (task.isBubbleToFullscreen()) {
        if (task.isBubbleToFullscreen() || task.isBubbleToSplit(splitScreenController)) {
            taskView.unregisterTask()
        } else {
            taskView.removeTask()