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

Commit 2d51d2b8 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Add functionality move tasks on IME signals" into main

parents ab7d8b52 4e405bf8
Loading
Loading
Loading
Loading
+14 −1
Original line number Diff line number Diff line
@@ -1552,13 +1552,25 @@ public abstract class WMShellModule {
    @WMSingleton
    @Provides
    static Optional<DesktopImeHandler> provideDesktopImeHandler(
            Optional<DesktopTasksController> desktopTasksController,
            Optional<DesktopUserRepositories> desktopUserRepositories,
            FocusTransitionObserver focusTransitionObserver,
            DisplayImeController displayImeController,
            DisplayController displayController,
            ShellTaskOrganizer shellTaskOrganizer,
            Transitions transitions,
            @ShellMainThread ShellExecutor mainExecutor,
            @ShellAnimationThread ShellExecutor animExecutor,
            Context context,
            ShellInit shellInit) {
        if (!DesktopModeStatus.canEnterDesktopMode(context)) {
            return Optional.empty();
        }
        return Optional.of(new DesktopImeHandler(displayImeController, shellInit));
        return Optional.of(
                new DesktopImeHandler(desktopTasksController.get(), desktopUserRepositories.get(),
                        focusTransitionObserver, shellTaskOrganizer,
                        displayImeController, displayController, transitions, mainExecutor,
                        animExecutor, context, shellInit));
    }

    //
@@ -1641,6 +1653,7 @@ public abstract class WMShellModule {
            Optional<DesktopDisplayEventHandler> desktopDisplayEventHandler,
            Optional<DesktopModeKeyGestureHandler> desktopModeKeyGestureHandler,
            Optional<SystemModalsTransitionHandler> systemModalsTransitionHandler,
            Optional<DesktopImeHandler> desktopImeHandler,
            ShellCrashHandler shellCrashHandler) {
        return new Object();
    }
+192 −4
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 * Copyright (C) 2025 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.
@@ -16,17 +16,45 @@

package com.android.wm.shell.desktopmode

import android.view.SurfaceControl
import android.animation.Animator
import android.animation.AnimatorSet
import android.app.ActivityManager
import android.content.Context
import android.graphics.Rect
import android.os.IBinder
import android.view.SurfaceControl.Transaction
import android.view.WindowManager.TRANSIT_CHANGE
import android.window.TransitionInfo
import android.window.TransitionRequestInfo
import android.window.WindowContainerTransaction
import com.android.internal.protolog.ProtoLog
import com.android.window.flags.Flags
import com.android.wm.shell.ShellTaskOrganizer
import com.android.wm.shell.common.DisplayController
import com.android.wm.shell.common.DisplayImeController
import com.android.wm.shell.common.DisplayImeController.ImePositionProcessor.IME_ANIMATION_DEFAULT
import com.android.wm.shell.common.ShellExecutor
import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
import com.android.wm.shell.shared.animation.Interpolators
import com.android.wm.shell.shared.animation.WindowAnimator
import com.android.wm.shell.sysui.ShellInit
import com.android.wm.shell.transition.FocusTransitionObserver
import com.android.wm.shell.transition.Transitions

/** Handles the interactions between IME and desktop tasks */
class DesktopImeHandler(
    private val tasksController: DesktopTasksController,
    private val userRepositories: DesktopUserRepositories,
    private val focusTransitionObserver: FocusTransitionObserver,
    private val shellTaskOrganizer: ShellTaskOrganizer,
    private val displayImeController: DisplayImeController,
    private val displayController: DisplayController,
    private val transitions: Transitions,
    private val mainExecutor: ShellExecutor,
    private val animExecutor: ShellExecutor,
    private val context: Context,
    shellInit: ShellInit,
) : DisplayImeController.ImePositionProcessor {
) : DisplayImeController.ImePositionProcessor, Transitions.TransitionHandler {

    init {
        shellInit.addInitCallback(::onInit, this)
@@ -38,14 +66,174 @@ class DesktopImeHandler(
        }
    }

    var topTask: ActivityManager.RunningTaskInfo? = null
    var previousBounds: Rect? = null

    override fun onImeStartPositioning(
        displayId: Int,
        hiddenTop: Int,
        shownTop: Int,
        showing: Boolean,
        isFloating: Boolean,
        t: SurfaceControl.Transaction?,
        t: Transaction?,
    ): Int {
        if (!tasksController.isAnyDeskActive(displayId) || isFloating) {
            return IME_ANIMATION_DEFAULT
        }

        if (showing) {
            // Only get the top task when the IME will be showing. Otherwise just restore
            // previously manipulated task.
            val currentTopTask =
                if (Flags.enableDisplayFocusInShellTransitions()) {
                    shellTaskOrganizer.getRunningTaskInfo(
                        focusTransitionObserver.globallyFocusedTaskId
                    )
                } else {
                    shellTaskOrganizer.getRunningTasks(displayId).find { taskInfo ->
                        taskInfo.isFocused
                    }
                } ?: return IME_ANIMATION_DEFAULT
            if (!userRepositories.current.isActiveTask(currentTopTask.taskId))
                return IME_ANIMATION_DEFAULT

            topTask = currentTopTask
            val taskBounds =
                currentTopTask.configuration.windowConfiguration?.bounds
                    ?: return IME_ANIMATION_DEFAULT
            val token = currentTopTask.token

            // Save the previous bounds to restore after IME disappears
            previousBounds = Rect(taskBounds)
            val taskHeight = taskBounds.height()
            val stableBounds = Rect()
            val displayLayout =
                displayController.getDisplayLayout(displayId)
                    ?: error("Expected non-null display layout for displayId")
            displayLayout.getStableBounds(stableBounds)
            var finalBottom = 0
            var finalTop = 0
            // If the IME will be covering some part of the task, we need to move the task.
            if (taskBounds.bottom > shownTop) {
                if ((shownTop - stableBounds.top) > taskHeight) {
                    // If the distance between the IME and the top of stable bounds is greater
                    // than the height of the task, keep the task right on top of IME.
                    finalBottom = shownTop
                    finalTop = shownTop - taskHeight
                } else {
                    // Else just move the task up to the top of stable bounds.
                    finalTop = stableBounds.top
                    finalBottom = stableBounds.top + taskHeight
                }
            }

            val finalBounds = Rect(taskBounds.left, finalTop, taskBounds.right, finalBottom)

            logD("Moving task %d due to IME", currentTopTask.taskId)
            val wct = WindowContainerTransaction().setBounds(token, finalBounds)
            transitions.startTransition(TRANSIT_CHANGE, wct, this)
        } else {

            // Restore the previous bounds if they exist
            val finalBounds = previousBounds ?: return IME_ANIMATION_DEFAULT
            val previousTask = topTask ?: return IME_ANIMATION_DEFAULT

            logD("Restoring bounds of task %d due to IME", previousTask.taskId)
            val wct = WindowContainerTransaction().setBounds(previousTask.token, finalBounds)
            transitions.startTransition(TRANSIT_CHANGE, wct, this)
        }
        return IME_ANIMATION_DEFAULT
    }

    override fun startAnimation(
        transition: IBinder,
        info: TransitionInfo,
        startTransaction: Transaction,
        finishTransaction: Transaction,
        finishCallback: Transitions.TransitionFinishCallback,
    ): Boolean {
        val animations = mutableListOf<Animator>()
        val onAnimFinish: (Animator) -> Unit = { animator ->
            mainExecutor.execute {
                // Animation completed
                animations.remove(animator)
                if (animations.isEmpty()) {
                    // All animations completed, finish the transition
                    finishCallback.onTransitionFinished(/* wct= */ null)
                }
            }
        }

        val checkChangeMode = { change: TransitionInfo.Change -> change.mode == TRANSIT_CHANGE }
        animations +=
            info.changes
                .filter {
                    checkChangeMode(it) &&
                        it.taskInfo?.taskId?.let { taskId ->
                            userRepositories.current.isActiveTask(taskId)
                        } == true
                }
                .mapNotNull { createAnimation(it, finishTransaction, onAnimFinish) }
        if (animations.isEmpty()) return false
        animExecutor.execute { animations.forEach(Animator::start) }
        return true
    }

    private fun createAnimation(
        change: TransitionInfo.Change,
        finishTransaction: Transaction,
        onAnimFinish: (Animator) -> Unit,
    ): Animator? {
        val t = Transaction()
        val sc = change.leash
        finishTransaction.show(sc)
        val displayContext =
            change.taskInfo?.let { displayController.getDisplayContext(it.displayId) }
        if (displayContext == null) return null

        val boundsAnimator =
            WindowAnimator.createBoundsAnimator(
                displayMetrics = context.resources.displayMetrics,
                boundsAnimDef = boundsChangeAnimatorDef,
                change = change,
                transaction = t,
            )

        val listener =
            object : Animator.AnimatorListener {
                override fun onAnimationStart(animator: Animator) {}

                override fun onAnimationCancel(animator: Animator) {}

                override fun onAnimationRepeat(animator: Animator) = Unit

                override fun onAnimationEnd(animator: Animator) {
                    onAnimFinish(animator)
                }
            }
        return AnimatorSet().apply {
            play(boundsAnimator)
            addListener(listener)
        }
    }

    override fun handleRequest(
        transition: IBinder,
        request: TransitionRequestInfo,
    ): WindowContainerTransaction? = null

    private fun logD(msg: String, vararg arguments: Any?) {
        ProtoLog.d(WM_SHELL_DESKTOP_MODE, "%s: $msg", TAG, *arguments)
    }

    private companion object {
        private const val TAG = "DesktopImeHandler"

        private val boundsChangeAnimatorDef =
            WindowAnimator.BoundsAnimationParams(
                durationMs = RESIZE_DURATION_MS,
                interpolator = Interpolators.STANDARD_ACCELERATE,
            )
        private const val RESIZE_DURATION_MS = 300L
    }
}
+316 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.desktopmode

import android.app.ActivityManager.RunningTaskInfo
import android.app.WindowConfiguration
import android.content.Context
import android.graphics.Rect
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.view.Display.DEFAULT_DISPLAY
import android.view.WindowManager.TRANSIT_CHANGE
import android.window.WindowContainerTransaction
import com.android.window.flags.Flags
import com.android.wm.shell.ShellTaskOrganizer
import com.android.wm.shell.ShellTestCase
import com.android.wm.shell.common.DisplayController
import com.android.wm.shell.common.DisplayImeController
import com.android.wm.shell.common.DisplayLayout
import com.android.wm.shell.common.ShellExecutor
import com.android.wm.shell.desktopmode.DesktopTestHelpers.createFreeformTask
import com.android.wm.shell.sysui.ShellInit
import com.android.wm.shell.transition.FocusTransitionObserver
import com.android.wm.shell.transition.Transitions
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import org.junit.Before
import org.mockito.ArgumentCaptor
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.spy
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

class DesktopImeHandlerTest : ShellTestCase() {

    private val testExecutor = mock<ShellExecutor>()
    private val transitions = mock<Transitions>()
    private val context = mock<Context>()
    private val shellTaskOrganizer = mock<ShellTaskOrganizer>()
    private val tasksController = mock<DesktopTasksController>()
    private val displayLayout = mock<DisplayLayout>()

    private val displayImeController = mock<DisplayImeController>()
    private val displayController = mock<DisplayController>()
    private val focusTransitionObserver = mock<FocusTransitionObserver>()
    private val desktopUserRepositories = mock<DesktopUserRepositories>()
    private val tasksRepository = mock<DesktopRepository>()

    private lateinit var imeHandler: DesktopImeHandler
    private lateinit var shellInit: ShellInit

    @Before
    fun setup() {
        shellInit = spy(ShellInit(testExecutor))

        whenever(tasksController.isAnyDeskActive(any())).thenReturn(true)
        whenever(displayController.getDisplayLayout(any())).thenReturn(displayLayout)
        whenever(desktopUserRepositories.current).thenReturn(tasksRepository)
        whenever(tasksRepository.isActiveTask(any())).thenReturn(true)

        imeHandler =
            DesktopImeHandler(
                tasksController,
                desktopUserRepositories,
                focusTransitionObserver,
                shellTaskOrganizer,
                displayImeController,
                displayController,
                transitions,
                mainExecutor = mock(),
                animExecutor = mock(),
                context,
                shellInit,
            )
    }

    @Test
    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_IME_BUGFIX)
    fun onImeStartPositioning_outsideOfDesktop_noOp() {
        setUpLandscapeDisplay()
        whenever(tasksController.isAnyDeskActive(any())).thenReturn(false)

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = true,
            isFloating = false,
            t = mock(),
        )

        // Moves the task up to the top of stable bounds
        verify(transitions, never()).startTransition(eq(TRANSIT_CHANGE), any(), anyOrNull())
    }

    @Test
    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_IME_BUGFIX)
    @DisableFlags(Flags.FLAG_ENABLE_DISPLAY_FOCUS_IN_SHELL_TRANSITIONS)
    fun onImeStartPositioning_movesLargeTaskToTopAndBack() {
        setUpLandscapeDisplay()
        val wct = ArgumentCaptor.forClass(WindowContainerTransaction::class.java)
        val taskBounds = Rect(0, 400, 500, 1600)
        var freeformTask = createFreeformTask(DEFAULT_DISPLAY, taskBounds)
        freeformTask.isFocused = true
        whenever(shellTaskOrganizer.getRunningTasks(any())).thenReturn(arrayListOf(freeformTask))

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = true,
            isFloating = false,
            t = mock(),
        )

        // Moves the task up to the top of stable bounds
        verify(transitions).startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask))
            .isEqualTo(
                Rect(
                    taskBounds.left,
                    STATUS_BAR_HEIGHT,
                    taskBounds.right,
                    STATUS_BAR_HEIGHT + taskBounds.height(),
                )
            )

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = false,
            isFloating = false,
            t = mock(),
        )

        // Moves the task back to original bounds
        verify(transitions, times(2))
            .startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask)).isEqualTo(taskBounds)
    }

    @Test
    @EnableFlags(
        Flags.FLAG_ENABLE_DESKTOP_IME_BUGFIX,
        Flags.FLAG_ENABLE_DISPLAY_FOCUS_IN_SHELL_TRANSITIONS,
    )
    fun onImeStartPositioning_displayFocusEnabled_movesLargeTaskToTopAndBack() {
        setUpLandscapeDisplay()
        val wct = ArgumentCaptor.forClass(WindowContainerTransaction::class.java)
        val taskBounds = Rect(0, 400, 500, 1600)
        var freeformTask = createFreeformTask(DEFAULT_DISPLAY, taskBounds)
        freeformTask.isFocused = true
        whenever(focusTransitionObserver.globallyFocusedTaskId).thenReturn(freeformTask.taskId)
        whenever(shellTaskOrganizer.getRunningTaskInfo(freeformTask.taskId))
            .thenReturn(freeformTask)

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = true,
            isFloating = false,
            t = mock(),
        )

        // Moves the task up to the top of stable bounds
        verify(transitions).startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask))
            .isEqualTo(
                Rect(
                    taskBounds.left,
                    STATUS_BAR_HEIGHT,
                    taskBounds.right,
                    STATUS_BAR_HEIGHT + taskBounds.height(),
                )
            )

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = false,
            isFloating = false,
            t = mock(),
        )

        // Moves the task back to original bounds
        verify(transitions, times(2))
            .startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask)).isEqualTo(taskBounds)
    }

    @Test
    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_IME_BUGFIX)
    @DisableFlags(Flags.FLAG_ENABLE_DISPLAY_FOCUS_IN_SHELL_TRANSITIONS)
    fun onImeStartPositioning_movesSmallTaskToTopAndBack() {
        setUpLandscapeDisplay()
        val wct = ArgumentCaptor.forClass(WindowContainerTransaction::class.java)
        val taskBounds = Rect(0, 1000, 500, 1600)
        var freeformTask = createFreeformTask(DEFAULT_DISPLAY, taskBounds)
        freeformTask.isFocused = true
        whenever(shellTaskOrganizer.getRunningTasks(any())).thenReturn(arrayListOf(freeformTask))

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = true,
            isFloating = false,
            t = mock(),
        )

        // Moves the task up to the top of stable bounds
        verify(transitions).startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask))
            .isEqualTo(
                Rect(
                    taskBounds.left,
                    IME_HEIGHT - taskBounds.height(),
                    taskBounds.right,
                    IME_HEIGHT,
                )
            )

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = false,
            isFloating = false,
            t = mock(),
        )

        // Moves the task back to original bounds
        verify(transitions, times(2))
            .startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
        assertThat(findBoundsChange(wct.value, freeformTask)).isEqualTo(taskBounds)
    }

    @Test
    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_IME_BUGFIX)
    fun onImeStartPositioning_floatingIme_noOp() {
        setUpLandscapeDisplay()
        val wct = ArgumentCaptor.forClass(WindowContainerTransaction::class.java)
        val taskBounds = Rect(0, 400, 500, 1600)
        var freeformTask = createFreeformTask(DEFAULT_DISPLAY, taskBounds)
        freeformTask.isFocused = true
        whenever(shellTaskOrganizer.getRunningTasks(any())).thenReturn(arrayListOf(freeformTask))

        imeHandler.onImeStartPositioning(
            DEFAULT_DISPLAY,
            hiddenTop = DISPLAY_DIMENSION_SHORT,
            shownTop = IME_HEIGHT,
            showing = true,
            isFloating = true,
            t = mock(),
        )

        // No transition is started because the IME is floating
        verify(transitions, never()).startTransition(eq(TRANSIT_CHANGE), wct.capture(), anyOrNull())
    }

    private fun findBoundsChange(wct: WindowContainerTransaction, task: RunningTaskInfo): Rect? =
        wct.changes.entries
            .find { (token, change) ->
                token == task.token.asBinder() &&
                    (change.windowSetMask and WindowConfiguration.WINDOW_CONFIG_BOUNDS) != 0
            }
            ?.value
            ?.configuration
            ?.windowConfiguration
            ?.bounds

    private fun setUpLandscapeDisplay() {
        whenever(displayLayout.width()).thenReturn(DISPLAY_DIMENSION_LONG)
        whenever(displayLayout.height()).thenReturn(DISPLAY_DIMENSION_SHORT)
        val stableBounds =
            Rect(
                0,
                STATUS_BAR_HEIGHT,
                DISPLAY_DIMENSION_LONG,
                DISPLAY_DIMENSION_SHORT - TASKBAR_FRAME_HEIGHT,
            )
        whenever(displayLayout.getStableBounds(any())).thenAnswer { i ->
            (i.arguments.first() as Rect).set(stableBounds)
        }
    }

    private companion object {
        private const val DISPLAY_DIMENSION_SHORT = 1600
        private const val DISPLAY_DIMENSION_LONG = 2560
        private const val TASKBAR_FRAME_HEIGHT = 200
        private const val STATUS_BAR_HEIGHT = 76
        private const val IME_HEIGHT = 840
    }
}