Loading libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultMixedTransition.java +51 −27 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.wm.shell.transition; import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED; import static android.view.WindowManager.TRANSIT_PIP; import static android.view.WindowManager.TRANSIT_TO_BACK; Loading Loading @@ -233,27 +234,11 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { splitHandler.dismissSplitInBackground(EXIT_REASON_FULLSCREEN_REQUEST); } TransitionInfo.Change pipChange = null; final TransitionInfo pipInfo = subCopy(info, TRANSIT_PIP, false /* withChanges */); for (int i = info.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = info.getChanges().get(i); if (mPipHandler.isEnteringPip(change, info.getType())) { if (pipChange != null) { throw new IllegalStateException("More than 1 pip-entering changes in one" + " transition? " + info); } pipChange = change; info.getChanges().remove(i); pipInfo.addChange(pipChange); } else if (change.getTaskInfo() == null && change.getParent() != null && pipChange != null && change.getParent().equals(pipChange.getContainer())) { // Cache the PiP activity if it's a target and cached pip task change is its parent; // note that we are bottom-to-top, so if such activity has a task // that is also a target, then it must have been cached already as pipChange. TransitionInfo.Change pipActivityChange = info.getChanges().remove(i); pipInfo.getChanges().addFirst(pipActivityChange); } } final TransitionInfo pipInfo = removePipChangesFrom(info); final boolean hasPipChange = !pipInfo.getChanges().isEmpty(); final TransitionInfo.Change enterPipChange = pipInfo.getChanges().stream().filter(change -> mPipHandler.isEnteringPip(change, info.getType())).findFirst().orElse(null); TransitionInfo.Change desktopChange = null; for (int i = info.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = info.getChanges().get(i); Loading @@ -273,8 +258,8 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { if (mInFlightSubAnimations > 0) return; finishCallback.onTransitionFinished(mFinishWCT); }; if ((pipChange == null && desktopChange == null) || (pipChange != null && desktopChange != null)) { if ((!hasPipChange && desktopChange == null) || (hasPipChange && desktopChange != null)) { // Don't split the transition. Let the leftovers handler handle it all. // TODO: b/? - split the transition into three pieces when there's both a PIP and a // desktop change are present. For example, during remote intent open over a desktop Loading @@ -287,7 +272,7 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { } } return false; } else if (pipChange != null && desktopChange == null) { } else if (hasPipChange && desktopChange == null) { ProtoLog.v(ShellProtoLogGroup.WM_SHELL_TRANSITIONS, "Splitting PIP into a separate" + " animation because remote-animation likely doesn't support it #%d", info.getDebugId()); Loading @@ -299,8 +284,8 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { if (PipFlags.isPip2ExperimentEnabled()) { mPipHandler.startAnimation(mTransition, pipInfo, startTransaction, finishTransaction, finishCB); } else { mPipHandler.startEnterAnimation(pipChange, otherStartT, finishTransaction, } else if (enterPipChange != null) { mPipHandler.startEnterAnimation(enterPipChange, otherStartT, finishTransaction, finishCB); } Loading @@ -314,7 +299,7 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { mTransition, info, startTransaction, finishTransaction, finishCB, mMixedHandler); return true; } else if (pipChange == null && desktopChange != null) { } else if (!hasPipChange && desktopChange != null) { ProtoLog.v(ShellProtoLogGroup.WM_SHELL_TRANSITIONS, "Splitting desktop change into a" + "separate animation because remote-animation likely doesn't support" + "it #%d", info.getDebugId()); Loading @@ -340,6 +325,45 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { } } @NonNull private TransitionInfo removePipChangesFrom(@NonNull TransitionInfo outInfo) { final TransitionInfo pipInfo = subCopy(outInfo, // In PiP2, sub-flight PiP transition doesn't have to be entering PiP. PipFlags.isPip2ExperimentEnabled() ? outInfo.getType() : TRANSIT_PIP, false /* withChanges */); // Cache enter PiP change separately to find config-at-end activity change if present. TransitionInfo.Change enterPipChange = null; for (int i = outInfo.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = outInfo.getChanges().get(i); if (mPipHandler.isEnteringPip(change, outInfo.getType())) { if (enterPipChange != null) { throw new IllegalStateException("More than 1 enter-pip changes in one" + " transition? " + outInfo); } enterPipChange = change; outInfo.getChanges().remove(i); pipInfo.getChanges().addFirst(enterPipChange); } else if (PipFlags.isPip2ExperimentEnabled() && change.getTaskInfo() != null && change.getTaskInfo().getWindowingMode() == WINDOWING_MODE_PINNED) { // Sometimes a PiP change that isn't an entering change could be collected into // a different transition. outInfo.getChanges().remove(i); pipInfo.addChange(change); } else if (change.getTaskInfo() == null && enterPipChange != null && change.getParent() != null && change.getParent().equals(enterPipChange.getContainer())) { // Cache the PiP activity if it's a target and cached pip task change is its parent; // note that we are bottom-to-top, so if such activity has a task // that is also a target, then it must have been cached already as pipChange. TransitionInfo.Change pipActivityChange = outInfo.getChanges().remove(i); pipInfo.getChanges().addFirst(pipActivityChange); } } return pipInfo; } static boolean animateEnterBubbles( @NonNull IBinder transition, @NonNull TransitionInfo info, Loading libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/DefaultMixedTransitionTest.kt 0 → 100644 +183 −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.transition import android.app.ActivityManager import android.app.WindowConfiguration import android.os.IBinder import android.platform.test.annotations.EnableFlags import android.view.SurfaceControl import android.view.WindowManager import android.window.TransitionInfo import android.window.WindowContainerToken import androidx.test.filters.SmallTest import com.android.wm.shell.Flags import com.android.wm.shell.ShellTestCase import com.android.wm.shell.activityembedding.ActivityEmbeddingController import com.android.wm.shell.bubbles.BubbleTransitions import com.android.wm.shell.desktopmode.DesktopTasksController import com.android.wm.shell.keyguard.KeyguardTransitionHandler import com.android.wm.shell.pip.PipTransitionController import com.android.wm.shell.splitscreen.StageCoordinator import com.android.wm.shell.unfold.UnfoldTransitionHandler import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever /** * Tests for [DefaultMixedTransition] * Build & Run: atest WMShellUnitTests:DefaultMixedTransitionTest */ @SmallTest class DefaultMixedTransitionTest : ShellTestCase() { private val mPlayer = mock<Transitions>() private val mMixedHandler = mock<MixedTransitionHandler>() private val mPipHandler = mock<PipTransitionController>() private val mSplitHandler = mock<StageCoordinator>() private val mKeyguardHandler = mock<KeyguardTransitionHandler>() private val mUnfoldHandler = mock<UnfoldTransitionHandler>() private val mActivityEmbeddingController = mock<ActivityEmbeddingController>() private val mDesktopTasksController = mock<DesktopTasksController>() private val mBubbleTransitions = mock<BubbleTransitions>() private val mMockTransition = mock<IBinder>() // Mocks for startAnimation arguments, initialized inline private val mStartT = mock<SurfaceControl.Transaction>() private val mFinishT = mock<SurfaceControl.Transaction>() private val mFinishCallback = mock<Transitions.TransitionFinishCallback>() private val mockPipToken = mock<WindowContainerToken>() private val mockPipLeash = mock<SurfaceControl>() // Other mocks needed for the test logic, initialized inline private val mRemoteTransitionHandler = mock<RemoteTransitionHandler>() private val mRemoteChange = mock<TransitionInfo.Change>() @Before fun setUp() { whenever(mPlayer.remoteTransitionHandler).thenReturn(mRemoteTransitionHandler) } @EnableFlags(Flags.FLAG_ENABLE_PIP2) @Test fun remoteAndPipOrDesktop_remoteAndEnterPip_sendPipChangeToPipHandler() { // Mock dependencies to prevent side-effects and isolate logic for PiP and remote only. whenever(mSplitHandler.transitionImpliesSplitToFullscreen(any())).thenReturn(false) whenever(mDesktopTasksController.isDesktopChange(any(), any())).thenReturn(false) whenever(mPlayer.dispatchTransition(any(), any(), any(), any(), any(), any())) .thenReturn(mRemoteTransitionHandler) val defaultMixedTransition = getDefaultMixedTransition( DefaultMixedHandler.MixedTransition.TYPE_OPTIONS_REMOTE_AND_PIP_OR_DESKTOP_CHANGE) val info = createRemoteTransitionInfo() val pipChange = createPipChange() whenever(mPipHandler.isEnteringPip(eq(pipChange), eq(info.type))).thenReturn(true) info.addChange(pipChange) val handled = defaultMixedTransition.startAnimation( mMockTransition, info, mStartT, mFinishT, mFinishCallback) // Verify that animation was resolved. assertThat(handled).isTrue() // Check that PiP handler was called into for the animation with PiP change in its info. val infoCaptor = argumentCaptor<TransitionInfo>() verify(mPipHandler, times(1)).startAnimation(eq(mMockTransition), infoCaptor.capture(), any(), any(), any()) // Assert that TransitionInfo was separated to contain PiP change only // and that transition type is carried over. val capturedInfo = infoCaptor.firstValue assertThat(capturedInfo.changes).containsExactly(pipChange) assertThat(capturedInfo.type).isEqualTo(info.type) } @EnableFlags(Flags.FLAG_ENABLE_PIP2) @Test fun remoteAndPipOrDesktop_remoteAndNonEnterPipChange_sendPipChangeToPipHandler() { // Mock dependencies to prevent side-effects and isolate logic for PiP and remote only. whenever(mSplitHandler.transitionImpliesSplitToFullscreen(any())).thenReturn(false) whenever(mDesktopTasksController.isDesktopChange(any(), any())).thenReturn(false) whenever(mPlayer.dispatchTransition(any(), any(), any(), any(), any(), any())) .thenReturn(mRemoteTransitionHandler) val defaultMixedTransition = getDefaultMixedTransition( DefaultMixedHandler.MixedTransition.TYPE_OPTIONS_REMOTE_AND_PIP_OR_DESKTOP_CHANGE) val info = createRemoteTransitionInfo() // Add a PiP change into the transition that is NOT entering PiP. val pipChange = createPipChange() whenever(mPipHandler.isEnteringPip(eq(pipChange), eq(info.type))).thenReturn(false) info.addChange(pipChange) val handled = defaultMixedTransition.startAnimation( mMockTransition, info, mStartT, mFinishT, mFinishCallback) // Verify that animation was resolved. assertThat(handled).isTrue() // Check that PiP handler was called into for the animation with PiP change in its info. val infoCaptor = argumentCaptor<TransitionInfo>() verify(mPipHandler, times(1)).startAnimation(eq(mMockTransition), infoCaptor.capture(), any(), any(), any()) // Assert that TransitionInfo was separated to contain PiP change only // and that transition type is carried over; this should be done even with non-enter PiP // changes. val capturedInfo = infoCaptor.firstValue assertThat(capturedInfo.changes).containsExactly(pipChange) assertThat(capturedInfo.type).isEqualTo(info.type) } private fun createRemoteTransitionInfo(): TransitionInfo { val info = TransitionInfo(WindowManager.TRANSIT_TO_FRONT, 0 /* flags */) info.addChange(mRemoteChange) return info } private fun createPipChange(): TransitionInfo.Change { val pipChange = TransitionInfo.Change(mockPipToken, mockPipLeash) val pipTaskInfo = ActivityManager.RunningTaskInfo() pipTaskInfo.configuration.windowConfiguration.windowingMode = WindowConfiguration.WINDOWING_MODE_PINNED pipChange.taskInfo = pipTaskInfo return pipChange } private fun getDefaultMixedTransition(type: Int): DefaultMixedTransition { return DefaultMixedTransition( type, mMockTransition, mPlayer, mMixedHandler, mPipHandler, mSplitHandler, mKeyguardHandler, mUnfoldHandler, mActivityEmbeddingController, mDesktopTasksController, mBubbleTransitions ) } } No newline at end of file Loading
libs/WindowManager/Shell/src/com/android/wm/shell/transition/DefaultMixedTransition.java +51 −27 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.wm.shell.transition; import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED; import static android.view.WindowManager.TRANSIT_PIP; import static android.view.WindowManager.TRANSIT_TO_BACK; Loading Loading @@ -233,27 +234,11 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { splitHandler.dismissSplitInBackground(EXIT_REASON_FULLSCREEN_REQUEST); } TransitionInfo.Change pipChange = null; final TransitionInfo pipInfo = subCopy(info, TRANSIT_PIP, false /* withChanges */); for (int i = info.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = info.getChanges().get(i); if (mPipHandler.isEnteringPip(change, info.getType())) { if (pipChange != null) { throw new IllegalStateException("More than 1 pip-entering changes in one" + " transition? " + info); } pipChange = change; info.getChanges().remove(i); pipInfo.addChange(pipChange); } else if (change.getTaskInfo() == null && change.getParent() != null && pipChange != null && change.getParent().equals(pipChange.getContainer())) { // Cache the PiP activity if it's a target and cached pip task change is its parent; // note that we are bottom-to-top, so if such activity has a task // that is also a target, then it must have been cached already as pipChange. TransitionInfo.Change pipActivityChange = info.getChanges().remove(i); pipInfo.getChanges().addFirst(pipActivityChange); } } final TransitionInfo pipInfo = removePipChangesFrom(info); final boolean hasPipChange = !pipInfo.getChanges().isEmpty(); final TransitionInfo.Change enterPipChange = pipInfo.getChanges().stream().filter(change -> mPipHandler.isEnteringPip(change, info.getType())).findFirst().orElse(null); TransitionInfo.Change desktopChange = null; for (int i = info.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = info.getChanges().get(i); Loading @@ -273,8 +258,8 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { if (mInFlightSubAnimations > 0) return; finishCallback.onTransitionFinished(mFinishWCT); }; if ((pipChange == null && desktopChange == null) || (pipChange != null && desktopChange != null)) { if ((!hasPipChange && desktopChange == null) || (hasPipChange && desktopChange != null)) { // Don't split the transition. Let the leftovers handler handle it all. // TODO: b/? - split the transition into three pieces when there's both a PIP and a // desktop change are present. For example, during remote intent open over a desktop Loading @@ -287,7 +272,7 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { } } return false; } else if (pipChange != null && desktopChange == null) { } else if (hasPipChange && desktopChange == null) { ProtoLog.v(ShellProtoLogGroup.WM_SHELL_TRANSITIONS, "Splitting PIP into a separate" + " animation because remote-animation likely doesn't support it #%d", info.getDebugId()); Loading @@ -299,8 +284,8 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { if (PipFlags.isPip2ExperimentEnabled()) { mPipHandler.startAnimation(mTransition, pipInfo, startTransaction, finishTransaction, finishCB); } else { mPipHandler.startEnterAnimation(pipChange, otherStartT, finishTransaction, } else if (enterPipChange != null) { mPipHandler.startEnterAnimation(enterPipChange, otherStartT, finishTransaction, finishCB); } Loading @@ -314,7 +299,7 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { mTransition, info, startTransaction, finishTransaction, finishCB, mMixedHandler); return true; } else if (pipChange == null && desktopChange != null) { } else if (!hasPipChange && desktopChange != null) { ProtoLog.v(ShellProtoLogGroup.WM_SHELL_TRANSITIONS, "Splitting desktop change into a" + "separate animation because remote-animation likely doesn't support" + "it #%d", info.getDebugId()); Loading @@ -340,6 +325,45 @@ class DefaultMixedTransition extends DefaultMixedHandler.MixedTransition { } } @NonNull private TransitionInfo removePipChangesFrom(@NonNull TransitionInfo outInfo) { final TransitionInfo pipInfo = subCopy(outInfo, // In PiP2, sub-flight PiP transition doesn't have to be entering PiP. PipFlags.isPip2ExperimentEnabled() ? outInfo.getType() : TRANSIT_PIP, false /* withChanges */); // Cache enter PiP change separately to find config-at-end activity change if present. TransitionInfo.Change enterPipChange = null; for (int i = outInfo.getChanges().size() - 1; i >= 0; --i) { TransitionInfo.Change change = outInfo.getChanges().get(i); if (mPipHandler.isEnteringPip(change, outInfo.getType())) { if (enterPipChange != null) { throw new IllegalStateException("More than 1 enter-pip changes in one" + " transition? " + outInfo); } enterPipChange = change; outInfo.getChanges().remove(i); pipInfo.getChanges().addFirst(enterPipChange); } else if (PipFlags.isPip2ExperimentEnabled() && change.getTaskInfo() != null && change.getTaskInfo().getWindowingMode() == WINDOWING_MODE_PINNED) { // Sometimes a PiP change that isn't an entering change could be collected into // a different transition. outInfo.getChanges().remove(i); pipInfo.addChange(change); } else if (change.getTaskInfo() == null && enterPipChange != null && change.getParent() != null && change.getParent().equals(enterPipChange.getContainer())) { // Cache the PiP activity if it's a target and cached pip task change is its parent; // note that we are bottom-to-top, so if such activity has a task // that is also a target, then it must have been cached already as pipChange. TransitionInfo.Change pipActivityChange = outInfo.getChanges().remove(i); pipInfo.getChanges().addFirst(pipActivityChange); } } return pipInfo; } static boolean animateEnterBubbles( @NonNull IBinder transition, @NonNull TransitionInfo info, Loading
libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/DefaultMixedTransitionTest.kt 0 → 100644 +183 −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.transition import android.app.ActivityManager import android.app.WindowConfiguration import android.os.IBinder import android.platform.test.annotations.EnableFlags import android.view.SurfaceControl import android.view.WindowManager import android.window.TransitionInfo import android.window.WindowContainerToken import androidx.test.filters.SmallTest import com.android.wm.shell.Flags import com.android.wm.shell.ShellTestCase import com.android.wm.shell.activityembedding.ActivityEmbeddingController import com.android.wm.shell.bubbles.BubbleTransitions import com.android.wm.shell.desktopmode.DesktopTasksController import com.android.wm.shell.keyguard.KeyguardTransitionHandler import com.android.wm.shell.pip.PipTransitionController import com.android.wm.shell.splitscreen.StageCoordinator import com.android.wm.shell.unfold.UnfoldTransitionHandler import com.google.common.truth.Truth.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever /** * Tests for [DefaultMixedTransition] * Build & Run: atest WMShellUnitTests:DefaultMixedTransitionTest */ @SmallTest class DefaultMixedTransitionTest : ShellTestCase() { private val mPlayer = mock<Transitions>() private val mMixedHandler = mock<MixedTransitionHandler>() private val mPipHandler = mock<PipTransitionController>() private val mSplitHandler = mock<StageCoordinator>() private val mKeyguardHandler = mock<KeyguardTransitionHandler>() private val mUnfoldHandler = mock<UnfoldTransitionHandler>() private val mActivityEmbeddingController = mock<ActivityEmbeddingController>() private val mDesktopTasksController = mock<DesktopTasksController>() private val mBubbleTransitions = mock<BubbleTransitions>() private val mMockTransition = mock<IBinder>() // Mocks for startAnimation arguments, initialized inline private val mStartT = mock<SurfaceControl.Transaction>() private val mFinishT = mock<SurfaceControl.Transaction>() private val mFinishCallback = mock<Transitions.TransitionFinishCallback>() private val mockPipToken = mock<WindowContainerToken>() private val mockPipLeash = mock<SurfaceControl>() // Other mocks needed for the test logic, initialized inline private val mRemoteTransitionHandler = mock<RemoteTransitionHandler>() private val mRemoteChange = mock<TransitionInfo.Change>() @Before fun setUp() { whenever(mPlayer.remoteTransitionHandler).thenReturn(mRemoteTransitionHandler) } @EnableFlags(Flags.FLAG_ENABLE_PIP2) @Test fun remoteAndPipOrDesktop_remoteAndEnterPip_sendPipChangeToPipHandler() { // Mock dependencies to prevent side-effects and isolate logic for PiP and remote only. whenever(mSplitHandler.transitionImpliesSplitToFullscreen(any())).thenReturn(false) whenever(mDesktopTasksController.isDesktopChange(any(), any())).thenReturn(false) whenever(mPlayer.dispatchTransition(any(), any(), any(), any(), any(), any())) .thenReturn(mRemoteTransitionHandler) val defaultMixedTransition = getDefaultMixedTransition( DefaultMixedHandler.MixedTransition.TYPE_OPTIONS_REMOTE_AND_PIP_OR_DESKTOP_CHANGE) val info = createRemoteTransitionInfo() val pipChange = createPipChange() whenever(mPipHandler.isEnteringPip(eq(pipChange), eq(info.type))).thenReturn(true) info.addChange(pipChange) val handled = defaultMixedTransition.startAnimation( mMockTransition, info, mStartT, mFinishT, mFinishCallback) // Verify that animation was resolved. assertThat(handled).isTrue() // Check that PiP handler was called into for the animation with PiP change in its info. val infoCaptor = argumentCaptor<TransitionInfo>() verify(mPipHandler, times(1)).startAnimation(eq(mMockTransition), infoCaptor.capture(), any(), any(), any()) // Assert that TransitionInfo was separated to contain PiP change only // and that transition type is carried over. val capturedInfo = infoCaptor.firstValue assertThat(capturedInfo.changes).containsExactly(pipChange) assertThat(capturedInfo.type).isEqualTo(info.type) } @EnableFlags(Flags.FLAG_ENABLE_PIP2) @Test fun remoteAndPipOrDesktop_remoteAndNonEnterPipChange_sendPipChangeToPipHandler() { // Mock dependencies to prevent side-effects and isolate logic for PiP and remote only. whenever(mSplitHandler.transitionImpliesSplitToFullscreen(any())).thenReturn(false) whenever(mDesktopTasksController.isDesktopChange(any(), any())).thenReturn(false) whenever(mPlayer.dispatchTransition(any(), any(), any(), any(), any(), any())) .thenReturn(mRemoteTransitionHandler) val defaultMixedTransition = getDefaultMixedTransition( DefaultMixedHandler.MixedTransition.TYPE_OPTIONS_REMOTE_AND_PIP_OR_DESKTOP_CHANGE) val info = createRemoteTransitionInfo() // Add a PiP change into the transition that is NOT entering PiP. val pipChange = createPipChange() whenever(mPipHandler.isEnteringPip(eq(pipChange), eq(info.type))).thenReturn(false) info.addChange(pipChange) val handled = defaultMixedTransition.startAnimation( mMockTransition, info, mStartT, mFinishT, mFinishCallback) // Verify that animation was resolved. assertThat(handled).isTrue() // Check that PiP handler was called into for the animation with PiP change in its info. val infoCaptor = argumentCaptor<TransitionInfo>() verify(mPipHandler, times(1)).startAnimation(eq(mMockTransition), infoCaptor.capture(), any(), any(), any()) // Assert that TransitionInfo was separated to contain PiP change only // and that transition type is carried over; this should be done even with non-enter PiP // changes. val capturedInfo = infoCaptor.firstValue assertThat(capturedInfo.changes).containsExactly(pipChange) assertThat(capturedInfo.type).isEqualTo(info.type) } private fun createRemoteTransitionInfo(): TransitionInfo { val info = TransitionInfo(WindowManager.TRANSIT_TO_FRONT, 0 /* flags */) info.addChange(mRemoteChange) return info } private fun createPipChange(): TransitionInfo.Change { val pipChange = TransitionInfo.Change(mockPipToken, mockPipLeash) val pipTaskInfo = ActivityManager.RunningTaskInfo() pipTaskInfo.configuration.windowConfiguration.windowingMode = WindowConfiguration.WINDOWING_MODE_PINNED pipChange.taskInfo = pipTaskInfo return pipChange } private fun getDefaultMixedTransition(type: Int): DefaultMixedTransition { return DefaultMixedTransition( type, mMockTransition, mPlayer, mMixedHandler, mPipHandler, mSplitHandler, mKeyguardHandler, mUnfoldHandler, mActivityEmbeddingController, mDesktopTasksController, mBubbleTransitions ) } } No newline at end of file