Loading libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java +14 −1 Original line number Diff line number Diff line Loading @@ -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)); } // Loading Loading @@ -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(); } Loading libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopImeHandler.kt +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. Loading @@ -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) Loading @@ -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 } } libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopImeHandlerTest.kt 0 → 100644 +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 } } Loading
libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java +14 −1 Original line number Diff line number Diff line Loading @@ -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)); } // Loading Loading @@ -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(); } Loading
libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopImeHandler.kt +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. Loading @@ -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) Loading @@ -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 } }
libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopImeHandlerTest.kt 0 → 100644 +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 } }