1 /*
<lambda>null2  *  Copyright (C) 2023 The Android Open Source Project
3  *
4  *  Licensed under the Apache License, Version 2.0 (the "License");
5  *  you may not use this file except in compliance with the License.
6  *  You may obtain a copy of the License at
7  *
8  *       http://www.apache.org/licenses/LICENSE-2.0
9  *
10  *  Unless required by applicable law or agreed to in writing, software
11  *  distributed under the License is distributed on an "AS IS" BASIS,
12  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  *  See the License for the specific language governing permissions and
14  *  limitations under the License.
15  *
16  */
17 
18 package com.android.quickstep.util
19 
20 import android.animation.Animator
21 import android.animation.AnimatorListenerAdapter
22 import android.animation.AnimatorSet
23 import android.animation.ObjectAnimator
24 import android.animation.ValueAnimator
25 import android.app.ActivityManager.RunningTaskInfo
26 import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
27 import android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW
28 import android.content.Context
29 import android.graphics.Bitmap
30 import android.graphics.Rect
31 import android.graphics.RectF
32 import android.graphics.drawable.Drawable
33 import android.view.RemoteAnimationTarget
34 import android.view.SurfaceControl
35 import android.view.SurfaceControl.Transaction
36 import android.view.View
37 import android.view.WindowManager.TRANSIT_OPEN
38 import android.view.WindowManager.TRANSIT_TO_FRONT
39 import android.window.TransitionInfo
40 import android.window.TransitionInfo.Change
41 import android.window.WindowContainerToken
42 import androidx.annotation.VisibleForTesting
43 import com.android.app.animation.Interpolators
44 import com.android.launcher3.DeviceProfile
45 import com.android.launcher3.Flags.enableOverviewIconMenu
46 import com.android.launcher3.InsettableFrameLayout
47 import com.android.launcher3.QuickstepTransitionManager
48 import com.android.launcher3.R
49 import com.android.launcher3.Utilities
50 import com.android.launcher3.anim.PendingAnimation
51 import com.android.launcher3.apppairs.AppPairIcon
52 import com.android.launcher3.config.FeatureFlags
53 import com.android.launcher3.logging.StatsLogManager.EventEnum
54 import com.android.launcher3.model.data.WorkspaceItemInfo
55 import com.android.launcher3.statehandlers.DepthController
56 import com.android.launcher3.statemanager.StateManager
57 import com.android.launcher3.taskbar.TaskbarActivityContext
58 import com.android.launcher3.uioverrides.QuickstepLauncher
59 import com.android.launcher3.util.MultiPropertyFactory.MULTI_PROPERTY_VALUE
60 import com.android.launcher3.util.SplitConfigurationOptions.SplitSelectSource
61 import com.android.launcher3.views.BaseDragLayer
62 import com.android.quickstep.TaskViewUtils
63 import com.android.quickstep.views.FloatingAppPairView
64 import com.android.quickstep.views.FloatingTaskView
65 import com.android.quickstep.views.GroupedTaskView
66 import com.android.quickstep.views.IconAppChipView
67 import com.android.quickstep.views.RecentsView
68 import com.android.quickstep.views.RecentsViewContainer
69 import com.android.quickstep.views.SplitInstructionsView
70 import com.android.quickstep.views.TaskThumbnailViewDeprecated
71 import com.android.quickstep.views.TaskView
72 import com.android.quickstep.views.TaskView.TaskContainer
73 import com.android.quickstep.views.TaskViewIcon
74 import com.android.wm.shell.shared.TransitionUtil
75 import java.util.Optional
76 import java.util.function.Supplier
77 
78 /**
79  * Utils class to help run animations for initiating split screen from launcher. Will be expanded
80  * with future refactors. Works in conjunction with the state stored in [SplitSelectStateController]
81  */
82 class SplitAnimationController(val splitSelectStateController: SplitSelectStateController) {
83     companion object {
84         // Break this out into maybe enums? Abstractions into its own classes? Tbd.
85         data class SplitAnimInitProps(
86             val originalView: View,
87             val originalBitmap: Bitmap?,
88             val iconDrawable: Drawable,
89             val fadeWithThumbnail: Boolean,
90             val isStagedTask: Boolean,
91             val iconView: View?
92         )
93     }
94 
95     /**
96      * Returns different elements to animate for the initial split selection animation depending on
97      * the state of the surface from which the split was initiated
98      */
99     fun getFirstAnimInitViews(
100         taskViewSupplier: Supplier<TaskView>,
101         splitSelectSourceSupplier: Supplier<SplitSelectSource?>
102     ): SplitAnimInitProps {
103         val splitSelectSource = splitSelectSourceSupplier.get()
104         if (!splitSelectStateController.isAnimateCurrentTaskDismissal) {
105             // Initiating from home
106             return SplitAnimInitProps(
107                 splitSelectSource!!.view,
108                 originalBitmap = null,
109                 splitSelectSource.drawable,
110                 fadeWithThumbnail = false,
111                 isStagedTask = true,
112                 iconView = null
113             )
114         } else if (splitSelectStateController.isDismissingFromSplitPair) {
115             // Initiating split from overview, but on a split pair
116             val taskView = taskViewSupplier.get()
117             for (container: TaskContainer in taskView.taskContainers) {
118                 if (container.task.getKey().getId() == splitSelectStateController.initialTaskId) {
119                     val drawable = getDrawable(container.iconView, splitSelectSource)
120                     return SplitAnimInitProps(
121                         container.thumbnailViewDeprecated,
122                         container.thumbnailViewDeprecated.thumbnail,
123                         drawable!!,
124                         fadeWithThumbnail = true,
125                         isStagedTask = true,
126                         iconView = container.iconView.asView()
127                     )
128                 }
129             }
130             throw IllegalStateException(
131                 "Attempting to init split from existing split pair " +
132                     "without a valid taskIdAttributeContainer"
133             )
134         } else {
135             // Initiating split from overview on fullscreen task TaskView
136             val taskView = taskViewSupplier.get()
137             taskView.taskContainers.first().let {
138                 val drawable = getDrawable(it.iconView, splitSelectSource)
139                 return SplitAnimInitProps(
140                     it.thumbnailViewDeprecated,
141                     it.thumbnailViewDeprecated.thumbnail,
142                     drawable!!,
143                     fadeWithThumbnail = true,
144                     isStagedTask = true,
145                     iconView = it.iconView.asView()
146                 )
147             }
148         }
149     }
150 
151     /**
152      * Returns the drawable that's provided in iconView, however if that is null it falls back to
153      * the drawable that's in splitSelectSource. TaskView's icon drawable can be null if the
154      * TaskView is scrolled far enough off screen
155      *
156      * @return [Drawable]
157      */
158     fun getDrawable(iconView: TaskViewIcon, splitSelectSource: SplitSelectSource?): Drawable? {
159         if (iconView.drawable == null && splitSelectSource != null) {
160             return splitSelectSource.drawable
161         }
162         return iconView.drawable
163     }
164 
165     /**
166      * When selecting first app from split pair, second app's thumbnail remains. This animates the
167      * second thumbnail by expanding it to take up the full taskViewWidth/Height and overlaying it
168      * with [TaskThumbnailViewDeprecated]'s splashView. Adds animations to the provided builder.
169      * Note: The app that **was not** selected as the first split app should be the container that's
170      * passed through.
171      *
172      * @param builder Adds animation to this
173      * @param taskIdAttributeContainer container of the app that **was not** selected
174      * @param isPrimaryTaskSplitting if true, task that was split would be top/left in the pair
175      *   (opposite of that representing [taskIdAttributeContainer])
176      */
177     fun addInitialSplitFromPair(
178         taskIdAttributeContainer: TaskContainer,
179         builder: PendingAnimation,
180         deviceProfile: DeviceProfile,
181         taskViewWidth: Int,
182         taskViewHeight: Int,
183         isPrimaryTaskSplitting: Boolean
184     ) {
185         val thumbnail = taskIdAttributeContainer.thumbnailViewDeprecated
186         val iconView: View = taskIdAttributeContainer.iconView.asView()
187         builder.add(ObjectAnimator.ofFloat(thumbnail, TaskThumbnailViewDeprecated.SPLASH_ALPHA, 1f))
188         thumbnail.setShowSplashForSplitSelection(true)
189         // With the new `IconAppChipView`, we always want to keep the chip pinned to the
190         // top left of the task / thumbnail.
191         if (enableOverviewIconMenu()) {
192             builder.add(
193                 ObjectAnimator.ofFloat(
194                     (iconView as IconAppChipView).splitTranslationX,
195                     MULTI_PROPERTY_VALUE,
196                     0f
197                 )
198             )
199             builder.add(
200                 ObjectAnimator.ofFloat(iconView.splitTranslationY, MULTI_PROPERTY_VALUE, 0f)
201             )
202         }
203         if (deviceProfile.isLeftRightSplit) {
204             // Center view first so scaling happens uniformly, alternatively we can move pivotX to 0
205             val centerThumbnailTranslationX: Float = (taskViewWidth - thumbnail.width) / 2f
206             val finalScaleX: Float = taskViewWidth.toFloat() / thumbnail.width
207             builder.add(
208                 ObjectAnimator.ofFloat(
209                     thumbnail,
210                     TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_X,
211                     centerThumbnailTranslationX
212                 )
213             )
214             if (!enableOverviewIconMenu()) {
215                 // icons are anchored from Gravity.END, so need to use negative translation
216                 val centerIconTranslationX: Float = (taskViewWidth - iconView.width) / 2f
217                 builder.add(
218                     ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, -centerIconTranslationX)
219                 )
220             }
221             builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_X, finalScaleX))
222 
223             // Reset other dimensions
224             // TODO(b/271468547), can't set Y translate to 0, need to account for top space
225             thumbnail.scaleY = 1f
226             val translateYResetVal: Float =
227                 if (!isPrimaryTaskSplitting) 0f
228                 else deviceProfile.overviewTaskThumbnailTopMarginPx.toFloat()
229             builder.add(
230                 ObjectAnimator.ofFloat(
231                     thumbnail,
232                     TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_Y,
233                     translateYResetVal
234                 )
235             )
236         } else {
237             val thumbnailSize = taskViewHeight - deviceProfile.overviewTaskThumbnailTopMarginPx
238             // Center view first so scaling happens uniformly, alternatively we can move pivotY to 0
239             // primary thumbnail has layout margin above it, so secondary thumbnail needs to take
240             // that into account. We should migrate to only using translations otherwise this
241             // asymmetry causes problems..
242 
243             // Icon defaults to center | horizontal, we add additional translation for split
244             var centerThumbnailTranslationY: Float
245 
246             // TODO(b/271468547), primary thumbnail has layout margin above it, so secondary
247             //  thumbnail needs to take that into account. We should migrate to only using
248             //  translations otherwise this asymmetry causes problems..
249             if (isPrimaryTaskSplitting) {
250                 centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f
251                 centerThumbnailTranslationY +=
252                     deviceProfile.overviewTaskThumbnailTopMarginPx.toFloat()
253             } else {
254                 centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f
255             }
256             val finalScaleY: Float = thumbnailSize.toFloat() / thumbnail.height
257             builder.add(
258                 ObjectAnimator.ofFloat(
259                     thumbnail,
260                     TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_Y,
261                     centerThumbnailTranslationY
262                 )
263             )
264 
265             if (!enableOverviewIconMenu()) {
266                 // icons are anchored from Gravity.END, so need to use negative translation
267                 builder.add(ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, 0f))
268             }
269             builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_Y, finalScaleY))
270 
271             // Reset other dimensions
272             thumbnail.scaleX = 1f
273             builder.add(
274                 ObjectAnimator.ofFloat(
275                     thumbnail,
276                     TaskThumbnailViewDeprecated.SPLIT_SELECT_TRANSLATE_X,
277                     0f
278                 )
279             )
280         }
281     }
282 
283     /**
284      * Creates and returns a fullscreen scrim to fade in behind the split confirm animation, and
285      * adds it to the provided [pendingAnimation].
286      */
287     fun addScrimBehindAnim(
288         pendingAnimation: PendingAnimation,
289         container: RecentsViewContainer,
290         context: Context
291     ): View {
292         val scrim = View(context)
293         val recentsView = container.getOverviewPanel<RecentsView<*, *>>()
294         val dp: DeviceProfile = container.getDeviceProfile()
295         // Add it before/under the most recently added first floating taskView
296         val firstAddedSplitViewIndex: Int =
297             container
298                 .getDragLayer()
299                 .indexOfChild(recentsView.splitSelectController.firstFloatingTaskView)
300         container.getDragLayer().addView(scrim, firstAddedSplitViewIndex)
301         // Make the scrim fullscreen
302         val lp = scrim.layoutParams as InsettableFrameLayout.LayoutParams
303         lp.topMargin = 0
304         lp.height = dp.heightPx
305         lp.width = dp.widthPx
306 
307         scrim.alpha = 0f
308         scrim.setBackgroundColor(
309             container.asContext().resources.getColor(R.color.taskbar_background_dark)
310         )
311         val timings = AnimUtils.getDeviceSplitToConfirmTimings(dp.isTablet) as SplitToConfirmTimings
312         pendingAnimation.setViewAlpha(
313             scrim,
314             1f,
315             Interpolators.clampToProgress(
316                 timings.backingScrimFadeInterpolator,
317                 timings.backingScrimFadeInStartOffset,
318                 timings.backingScrimFadeInEndOffset
319             )
320         )
321 
322         return scrim
323     }
324 
325     /** Does not play any animation if user is not currently in split selection state. */
326     fun playPlaceholderDismissAnim(container: RecentsViewContainer, splitDismissEvent: EventEnum) {
327         if (!splitSelectStateController.isSplitSelectActive) {
328             return
329         }
330 
331         val anim = createPlaceholderDismissAnim(container, splitDismissEvent, null /*duration*/)
332         anim.start()
333     }
334 
335     /**
336      * Returns [AnimatorSet] which slides initial split placeholder view offscreen and logs an event
337      * for why split is being dismissed
338      */
339     fun createPlaceholderDismissAnim(
340         container: RecentsViewContainer,
341         splitDismissEvent: EventEnum,
342         duration: Long?
343     ): AnimatorSet {
344         val animatorSet = AnimatorSet()
345         duration?.let { animatorSet.duration = it }
346         val recentsView: RecentsView<*, *> = container.getOverviewPanel()
347         val floatingTask: FloatingTaskView =
348             splitSelectStateController.firstFloatingTaskView ?: return animatorSet
349 
350         // We are in split selection state currently, transitioning to another state
351         val dragLayer: BaseDragLayer<*> = container.dragLayer
352         val onScreenRectF = RectF()
353         Utilities.getBoundsForViewInDragLayer(
354             dragLayer,
355             floatingTask,
356             Rect(0, 0, floatingTask.width, floatingTask.height),
357             false,
358             null,
359             onScreenRectF
360         )
361         // Get the part of the floatingTask that intersects with the DragLayer (i.e. the
362         // on-screen portion)
363         onScreenRectF.intersect(
364             dragLayer.left.toFloat(),
365             dragLayer.top.toFloat(),
366             dragLayer.right.toFloat(),
367             dragLayer.bottom.toFloat()
368         )
369         animatorSet.play(
370             ObjectAnimator.ofFloat(
371                 floatingTask,
372                 FloatingTaskView.PRIMARY_TRANSLATE_OFFSCREEN,
373                 recentsView.pagedOrientationHandler.getFloatingTaskOffscreenTranslationTarget(
374                     floatingTask,
375                     onScreenRectF,
376                     floatingTask.stagePosition,
377                     container.deviceProfile
378                 )
379             )
380         )
381         animatorSet.addListener(
382             object : AnimatorListenerAdapter() {
383                 override fun onAnimationEnd(animation: Animator) {
384                     splitSelectStateController.resetState()
385                     safeRemoveViewFromDragLayer(
386                         container,
387                         splitSelectStateController.splitInstructionsView
388                     )
389                 }
390             }
391         )
392         splitSelectStateController.logExitReason(splitDismissEvent)
393         return animatorSet
394     }
395 
396     /**
397      * Returns a [PendingAnimation] to animate in the chip to instruct a user to select a second app
398      * for splitscreen
399      */
400     fun getShowSplitInstructionsAnim(container: RecentsViewContainer): PendingAnimation {
401         safeRemoveViewFromDragLayer(container, splitSelectStateController.splitInstructionsView)
402         val splitInstructionsView = SplitInstructionsView.getSplitInstructionsView(container)
403         splitSelectStateController.splitInstructionsView = splitInstructionsView
404         val timings = AnimUtils.getDeviceOverviewToSplitTimings(container.deviceProfile.isTablet)
405         val anim = PendingAnimation(100 /*duration */)
406         splitInstructionsView.alpha = 0f
407         anim.setViewAlpha(
408             splitInstructionsView,
409             1f,
410             Interpolators.clampToProgress(
411                 Interpolators.LINEAR,
412                 timings.instructionsContainerFadeInStartOffset,
413                 timings.instructionsContainerFadeInEndOffset
414             )
415         )
416         anim.addFloat(
417             splitInstructionsView,
418             SplitInstructionsView.UNFOLD,
419             0.1f,
420             1f,
421             Interpolators.clampToProgress(
422                 Interpolators.EMPHASIZED_DECELERATE,
423                 timings.instructionsUnfoldStartOffset,
424                 timings.instructionsUnfoldEndOffset
425             )
426         )
427         return anim
428     }
429 
430     /** Removes the split instructions view from [launcher] drag layer. */
431     fun removeSplitInstructionsView(container: RecentsViewContainer) {
432         safeRemoveViewFromDragLayer(container, splitSelectStateController.splitInstructionsView)
433     }
434 
435     /**
436      * Animates the first placeholder view to fullscreen and launches its task.
437      *
438      * TODO(b/276361926): Remove the [resetCallback] option once contextual launches
439      */
440     fun playAnimPlaceholderToFullscreen(
441         container: RecentsViewContainer,
442         view: View,
443         resetCallback: Optional<Runnable>
444     ) {
445         val stagedTaskView = view as FloatingTaskView
446 
447         val isTablet: Boolean = container.deviceProfile.isTablet
448         val duration =
449             if (isTablet) SplitAnimationTimings.TABLET_CONFIRM_DURATION
450             else SplitAnimationTimings.PHONE_CONFIRM_DURATION
451 
452         val pendingAnimation = PendingAnimation(duration.toLong())
453         val firstTaskStartingBounds = Rect()
454         val firstTaskEndingBounds = Rect()
455 
456         stagedTaskView.getBoundsOnScreen(firstTaskStartingBounds)
457         container.dragLayer.getBoundsOnScreen(firstTaskEndingBounds)
458         splitSelectStateController.setLaunchingFirstAppFullscreen()
459 
460         stagedTaskView.addConfirmAnimation(
461             pendingAnimation,
462             RectF(firstTaskStartingBounds),
463             firstTaskEndingBounds,
464             false /* fadeWithThumbnail */,
465             true /* isStagedTask */
466         )
467 
468         pendingAnimation.addEndListener {
469             splitSelectStateController.launchInitialAppFullscreen {
470                 if (FeatureFlags.enableSplitContextually()) {
471                     splitSelectStateController.resetState()
472                 } else if (resetCallback.isPresent) {
473                     resetCallback.get().run()
474                 }
475             }
476         }
477 
478         pendingAnimation.buildAnim().start()
479     }
480 
481     /**
482      * Called when launching a specific pair of apps, e.g. when tapping a pair of apps in Overview,
483      * or launching an app pair from its Home icon. Selects the appropriate launch animation and
484      * plays it.
485      */
486     fun playSplitLaunchAnimation(
487         launchingTaskView: GroupedTaskView?,
488         launchingIconView: AppPairIcon?,
489         initialTaskId: Int,
490         secondTaskId: Int,
491         apps: Array<RemoteAnimationTarget>?,
492         wallpapers: Array<RemoteAnimationTarget>?,
493         nonApps: Array<RemoteAnimationTarget>?,
494         stateManager: StateManager<*, *>,
495         depthController: DepthController?,
496         info: TransitionInfo?,
497         t: Transaction?,
498         finishCallback: Runnable
499     ) {
500         if (info == null && t == null) {
501             // (Legacy animation) Tapping a split tile in Overview
502             // TODO (b/315490678): Ensure that this works with app pairs flow
503             check(apps != null && wallpapers != null && nonApps != null) {
504                 "trying to call composeRecentsSplitLaunchAnimatorLegacy, but encountered an " +
505                     "unexpected null"
506             }
507 
508             composeRecentsSplitLaunchAnimatorLegacy(
509                 launchingTaskView,
510                 initialTaskId,
511                 secondTaskId,
512                 apps,
513                 wallpapers,
514                 nonApps,
515                 stateManager,
516                 depthController,
517                 finishCallback
518             )
519 
520             return
521         }
522 
523         if (launchingTaskView != null) {
524             // Tapping a split tile in Overview
525             check(info != null && t != null) {
526                 "trying to launch a GroupedTaskView, but encountered an unexpected null"
527             }
528 
529             composeRecentsSplitLaunchAnimator(
530                 launchingTaskView,
531                 stateManager,
532                 depthController,
533                 info,
534                 t,
535                 finishCallback
536             )
537         } else if (launchingIconView != null) {
538             // Tapping an app pair icon
539             check(info != null && t != null) {
540                 "trying to launch an app pair icon, but encountered an unexpected null"
541             }
542             val appPairLaunchingAppIndex = hasChangesForBothAppPairs(launchingIconView, info)
543             if (appPairLaunchingAppIndex == -1) {
544                 // Launch split app pair animation
545                 composeIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback)
546             } else {
547                 composeFullscreenIconSplitLaunchAnimator(
548                     launchingIconView,
549                     info,
550                     t,
551                     finishCallback,
552                     appPairLaunchingAppIndex
553                 )
554             }
555         } else {
556             // Fallback case: simple fade-in animation
557             check(info != null && t != null) {
558                 "trying to call composeFadeInSplitLaunchAnimator, but encountered an " +
559                     "unexpected null"
560             }
561 
562             composeFadeInSplitLaunchAnimator(initialTaskId, secondTaskId, info, t, finishCallback)
563         }
564     }
565 
566     /**
567      * When the user taps a split tile in Overview, this will play the tasks' launch animation from
568      * the position of the tapped tile.
569      */
570     @VisibleForTesting
571     fun composeRecentsSplitLaunchAnimator(
572         launchingTaskView: GroupedTaskView,
573         stateManager: StateManager<*, *>,
574         depthController: DepthController?,
575         info: TransitionInfo,
576         t: Transaction,
577         finishCallback: Runnable
578     ) {
579         TaskViewUtils.composeRecentsSplitLaunchAnimator(
580             launchingTaskView,
581             stateManager,
582             depthController,
583             info,
584             t,
585             finishCallback
586         )
587     }
588 
589     /**
590      * LEGACY VERSION: When the user taps a split tile in Overview, this will play the tasks' launch
591      * animation from the position of the tapped tile.
592      */
593     @VisibleForTesting
594     fun composeRecentsSplitLaunchAnimatorLegacy(
595         launchingTaskView: GroupedTaskView?,
596         initialTaskId: Int,
597         secondTaskId: Int,
598         apps: Array<RemoteAnimationTarget>,
599         wallpapers: Array<RemoteAnimationTarget>,
600         nonApps: Array<RemoteAnimationTarget>,
601         stateManager: StateManager<*, *>,
602         depthController: DepthController?,
603         finishCallback: Runnable
604     ) {
605         TaskViewUtils.composeRecentsSplitLaunchAnimatorLegacy(
606             launchingTaskView,
607             initialTaskId,
608             secondTaskId,
609             apps,
610             wallpapers,
611             nonApps,
612             stateManager,
613             depthController,
614             finishCallback
615         )
616     }
617 
618     /**
619      * @return -1 if [transitionInfo] contains both apps of the app pair to be animated, otherwise
620      *   the integer index corresponding to [launchingIconView]'s contents for the single app to be
621      *   animated
622      */
623     fun hasChangesForBothAppPairs(
624         launchingIconView: AppPairIcon,
625         transitionInfo: TransitionInfo
626     ): Int {
627         val intent1 = launchingIconView.info.getFirstApp().intent.component?.packageName
628         val intent2 = launchingIconView.info.getSecondApp().intent.component?.packageName
629         var launchFullscreenAppIndex = -1
630         for (change in transitionInfo.changes) {
631             val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
632             if (
633                 TransitionUtil.isOpeningType(change.mode) &&
634                     taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN
635             ) {
636                 val baseIntent = taskInfo.baseIntent.component?.packageName
637                 if (baseIntent == intent1) {
638                     if (launchFullscreenAppIndex > -1) {
639                         launchFullscreenAppIndex = -1
640                         break
641                     }
642                     launchFullscreenAppIndex = 0
643                 } else if (baseIntent == intent2) {
644                     if (launchFullscreenAppIndex > -1) {
645                         launchFullscreenAppIndex = -1
646                         break
647                     }
648                     launchFullscreenAppIndex = 1
649                 }
650             }
651         }
652         return launchFullscreenAppIndex
653     }
654 
655     /**
656      * When the user taps an app pair icon to launch split, this will play the tasks' launch
657      * animation from the position of the icon.
658      *
659      * To find the root shell leash that we want to fade in, we do the following: The Changes we
660      * receive in transitionInfo are structured like this
661      *
662      *     Root (grandparent)
663      *     |
664      *     |--> Split Root 1 (left/top side parent) (WINDOWING_MODE_MULTI_WINDOW)
665      *     |   |
666      *     |    --> App 1 (left/top side child) (WINDOWING_MODE_MULTI_WINDOW)
667      *     |--> Divider
668      *     |--> Split Root 2 (right/bottom side parent) (WINDOWING_MODE_MULTI_WINDOW)
669      *         |
670      *          --> App 2 (right/bottom side child) (WINDOWING_MODE_MULTI_WINDOW)
671      *
672      * We want to animate the Root (grandparent) so that it affects both apps and the divider. To do
673      * this, we find one of the nodes with WINDOWING_MODE_MULTI_WINDOW (one of the left-side ones,
674      * for simplicity) and traverse the tree until we find the grandparent.
675      *
676      * This function is only called when we are animating the app pair in from scratch. It is NOT
677      * called when we are animating in from an existing visible TaskView tile or an app that is
678      * already on screen.
679      */
680     @VisibleForTesting
681     fun composeIconSplitLaunchAnimator(
682         launchingIconView: AppPairIcon,
683         transitionInfo: TransitionInfo,
684         t: Transaction,
685         finishCallback: Runnable
686     ) {
687         // If launching an app pair from Taskbar inside of an app context (no access to Launcher),
688         // use the scale-up animation
689         if (launchingIconView.context is TaskbarActivityContext) {
690             composeScaleUpLaunchAnimation(
691                 transitionInfo,
692                 t,
693                 finishCallback,
694                 WINDOWING_MODE_MULTI_WINDOW
695             )
696             return
697         }
698 
699         // Else we are in Launcher and can launch with the full icon stretch-and-split animation.
700         val launcher = QuickstepLauncher.getLauncher(launchingIconView.context)
701         val dp = launcher.deviceProfile
702 
703         // Create an AnimatorSet that will run both shell and launcher transitions together
704         val launchAnimation = AnimatorSet()
705         var rootCandidate: Change? = null
706 
707         for (change in transitionInfo.changes) {
708             val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
709 
710             // TODO (b/316490565): Replace this logic when SplitBounds is available to
711             //  startAnimation() and we can know the precise taskIds of launching tasks.
712             // Find a change that has WINDOWING_MODE_MULTI_WINDOW.
713             if (
714                 taskInfo.windowingMode == WINDOWING_MODE_MULTI_WINDOW &&
715                     (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT)
716             ) {
717                 // Check if it is a left/top app.
718                 val isLeftTopApp =
719                     (dp.isLeftRightSplit && change.endAbsBounds.left == 0) ||
720                         (!dp.isLeftRightSplit && change.endAbsBounds.top == 0)
721                 if (isLeftTopApp) {
722                     // Found one!
723                     rootCandidate = change
724                     break
725                 }
726             }
727         }
728 
729         // If we could not find a proper root candidate, something went wrong.
730         check(rootCandidate != null) { "Could not find a split root candidate" }
731 
732         // Find the place where our left/top app window meets the divider (used for the
733         // launcher side animation)
734         val dividerPos =
735             if (dp.isLeftRightSplit) rootCandidate.endAbsBounds.right
736             else rootCandidate.endAbsBounds.bottom
737 
738         // Recurse up the tree until parent is null, then we've found our root.
739         var parentToken: WindowContainerToken? = rootCandidate.parent
740         while (parentToken != null) {
741             rootCandidate = transitionInfo.getChange(parentToken) ?: break
742             parentToken = rootCandidate.parent
743         }
744 
745         // Make sure nothing weird happened, like getChange() returning null.
746         check(rootCandidate != null) { "Failed to find a root leash" }
747 
748         // Create a new floating view in Launcher, positioned above the launching icon
749         val drawableArea = launchingIconView.iconDrawableArea
750         val appIcon1 = launchingIconView.info.getFirstApp().newIcon(launchingIconView.context)
751         val appIcon2 = launchingIconView.info.getSecondApp().newIcon(launchingIconView.context)
752         appIcon1.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx)
753         appIcon2.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx)
754 
755         val floatingView =
756             FloatingAppPairView.getFloatingAppPairView(
757                 launcher,
758                 drawableArea,
759                 appIcon1,
760                 appIcon2,
761                 dividerPos
762             )
763         floatingView.bringToFront()
764 
765         launchAnimation.play(
766             getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)
767         )
768         launchAnimation.start()
769     }
770 
771     /**
772      * Similar to [composeIconSplitLaunchAnimator], but instructs [FloatingAppPairView] to animate a
773      * single fullscreen icon + background instead of for a pair
774      */
775     @VisibleForTesting
776     fun composeFullscreenIconSplitLaunchAnimator(
777         launchingIconView: AppPairIcon,
778         transitionInfo: TransitionInfo,
779         t: Transaction,
780         finishCallback: Runnable,
781         launchFullscreenIndex: Int
782     ) {
783         // If launching an app pair from Taskbar inside of an app context (no access to Launcher),
784         // use the scale-up animation
785         if (launchingIconView.context is TaskbarActivityContext) {
786             composeScaleUpLaunchAnimation(
787                 transitionInfo,
788                 t,
789                 finishCallback,
790                 WINDOWING_MODE_FULLSCREEN
791             )
792             return
793         }
794 
795         // Else we are in Launcher and can launch with the full icon stretch-and-split animation.
796         val launcher = QuickstepLauncher.getLauncher(launchingIconView.context)
797         val dp = launcher.deviceProfile
798 
799         // Create an AnimatorSet that will run both shell and launcher transitions together
800         val launchAnimation = AnimatorSet()
801 
802         val appInfo =
803             launchingIconView.info.getContents()[launchFullscreenIndex] as WorkspaceItemInfo
804         val intentToLaunch = appInfo.intent.component?.packageName
805         var rootCandidate: Change? = null
806         for (change in transitionInfo.changes) {
807             val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
808             val baseIntent = taskInfo.baseIntent.component?.packageName
809             if (
810                 TransitionUtil.isOpeningType(change.mode) &&
811                     taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN &&
812                     baseIntent == intentToLaunch
813             ) {
814                 rootCandidate = change
815             }
816         }
817 
818         // If we could not find a proper root candidate, something went wrong.
819         check(rootCandidate != null) { "Could not find a split root candidate" }
820 
821         // Recurse up the tree until parent is null, then we've found our root.
822         var parentToken: WindowContainerToken? = rootCandidate.parent
823         while (parentToken != null) {
824             rootCandidate = transitionInfo.getChange(parentToken) ?: break
825             parentToken = rootCandidate.parent
826         }
827 
828         // Make sure nothing weird happened, like getChange() returning null.
829         check(rootCandidate != null) { "Failed to find a root leash" }
830 
831         // Create a new floating view in Launcher, positioned above the launching icon
832         val drawableArea = launchingIconView.iconDrawableArea
833         val appIcon = appInfo.newIcon(launchingIconView.context)
834         appIcon.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx)
835 
836         val floatingView =
837             FloatingAppPairView.getFloatingAppPairView(
838                 launcher,
839                 drawableArea,
840                 appIcon,
841                 null /*appIcon2*/,
842                 0 /*dividerPos*/
843             )
844         floatingView.bringToFront()
845         launchAnimation.play(
846             getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)
847         )
848         launchAnimation.start()
849     }
850 
851     private fun getIconLaunchValueAnimator(
852         t: Transaction,
853         dp: com.android.launcher3.DeviceProfile,
854         finishCallback: Runnable,
855         launcher: QuickstepLauncher,
856         floatingView: FloatingAppPairView,
857         rootCandidate: Change
858     ): ValueAnimator {
859         val progressUpdater = ValueAnimator.ofFloat(0f, 1f)
860         val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet)
861         progressUpdater.setDuration(timings.getDuration().toLong())
862         progressUpdater.interpolator = Interpolators.LINEAR
863 
864         // Shell animation: the apps are revealed toward end of the launch animation
865         progressUpdater.addUpdateListener { valueAnimator: ValueAnimator ->
866             val progress =
867                 Interpolators.clampToProgress(
868                     Interpolators.LINEAR,
869                     valueAnimator.animatedFraction,
870                     timings.appRevealStartOffset,
871                     timings.appRevealEndOffset
872                 )
873 
874             // Set the alpha of the shell layer (2 apps + divider)
875             t.setAlpha(rootCandidate.leash, progress)
876             t.apply()
877         }
878 
879         progressUpdater.addUpdateListener(
880             object : MultiValueUpdateListener() {
881                 var mDx =
882                     FloatProp(
883                         floatingView.startingPosition.left,
884                         dp.widthPx / 2f - floatingView.startingPosition.width() / 2f,
885                         Interpolators.clampToProgress(
886                             timings.getStagedRectXInterpolator(),
887                             timings.stagedRectSlideStartOffset,
888                             timings.stagedRectSlideEndOffset
889                         )
890                     )
891                 var mDy =
892                     FloatProp(
893                         floatingView.startingPosition.top,
894                         dp.heightPx / 2f - floatingView.startingPosition.height() / 2f,
895                         Interpolators.clampToProgress(
896                             Interpolators.EMPHASIZED,
897                             timings.stagedRectSlideStartOffset,
898                             timings.stagedRectSlideEndOffset
899                         )
900                     )
901                 var mScaleX =
902                     FloatProp(
903                         1f /* start */,
904                         dp.widthPx / floatingView.startingPosition.width(),
905                         Interpolators.clampToProgress(
906                             Interpolators.EMPHASIZED,
907                             timings.stagedRectSlideStartOffset,
908                             timings.stagedRectSlideEndOffset
909                         )
910                     )
911                 var mScaleY =
912                     FloatProp(
913                         1f /* start */,
914                         dp.heightPx / floatingView.startingPosition.height(),
915                         Interpolators.clampToProgress(
916                             Interpolators.EMPHASIZED,
917                             timings.stagedRectSlideStartOffset,
918                             timings.stagedRectSlideEndOffset
919                         )
920                     )
921 
922                 override fun onUpdate(percent: Float, initOnly: Boolean) {
923                     floatingView.progress = percent
924                     floatingView.x = mDx.value
925                     floatingView.y = mDy.value
926                     floatingView.scaleX = mScaleX.value
927                     floatingView.scaleY = mScaleY.value
928                     floatingView.invalidate()
929                 }
930             }
931         )
932         progressUpdater.addListener(
933             object : AnimatorListenerAdapter() {
934                 override fun onAnimationEnd(animation: Animator) {
935                     safeRemoveViewFromDragLayer(launcher, floatingView)
936                     finishCallback.run()
937                 }
938             }
939         )
940 
941         return progressUpdater
942     }
943 
944     /**
945      * This is a scale-up-and-fade-in animation (34% to 100%) for launching an app in Overview when
946      * there is no visible associated tile to expand from. [windowingMode] helps determine whether
947      * we are looking for a split or a single fullscreen [Change]
948      */
949     @VisibleForTesting
950     fun composeScaleUpLaunchAnimation(
951         transitionInfo: TransitionInfo,
952         t: Transaction,
953         finishCallback: Runnable,
954         windowingMode: Int
955     ) {
956         val launchAnimation = AnimatorSet()
957         val progressUpdater = ValueAnimator.ofFloat(0f, 1f)
958         progressUpdater.setDuration(QuickstepTransitionManager.APP_LAUNCH_DURATION)
959         progressUpdater.interpolator = Interpolators.EMPHASIZED
960 
961         var rootCandidate: Change? = null
962 
963         for (change in transitionInfo.changes) {
964             val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
965 
966             // TODO (b/316490565): Replace this logic when SplitBounds is available to
967             //  startAnimation() and we can know the precise taskIds of launching tasks.
968             if (
969                 taskInfo.windowingMode == windowingMode &&
970                     (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT)
971             ) {
972                 // Found one!
973                 rootCandidate = change
974                 break
975             }
976         }
977 
978         // If we could not find a proper root candidate, something went wrong.
979         check(rootCandidate != null) { "Could not find a split root candidate" }
980 
981         // Recurse up the tree until parent is null, then we've found our root.
982         var parentToken: WindowContainerToken? = rootCandidate.parent
983         while (parentToken != null) {
984             rootCandidate = transitionInfo.getChange(parentToken) ?: break
985             parentToken = rootCandidate.parent
986         }
987 
988         // Make sure nothing weird happened, like getChange() returning null.
989         check(rootCandidate != null) { "Failed to find a root leash" }
990 
991         // Starting position is a 34% size tile centered in the middle of the screen.
992         // Ending position is the full device screen.
993         val screenBounds = rootCandidate.endAbsBounds
994         val startingScale = 0.34f
995         val startX =
996             screenBounds.left +
997                 ((screenBounds.right - screenBounds.left) * ((1 - startingScale) / 2f))
998         val startY =
999             screenBounds.top +
1000                 ((screenBounds.bottom - screenBounds.top) * ((1 - startingScale) / 2f))
1001         val endX = screenBounds.left
1002         val endY = screenBounds.top
1003 
1004         progressUpdater.addUpdateListener { valueAnimator: ValueAnimator ->
1005             val progress = valueAnimator.animatedFraction
1006 
1007             val x = startX + ((endX - startX) * progress)
1008             val y = startY + ((endY - startY) * progress)
1009             val scale = startingScale + ((1 - startingScale) * progress)
1010 
1011             t.setPosition(rootCandidate.leash, x, y)
1012             t.setScale(rootCandidate.leash, scale, scale)
1013             t.setAlpha(rootCandidate.leash, progress)
1014             t.apply()
1015         }
1016 
1017         // When animation ends,  run finishCallback
1018         progressUpdater.addListener(
1019             object : AnimatorListenerAdapter() {
1020                 override fun onAnimationEnd(animation: Animator) {
1021                     finishCallback.run()
1022                 }
1023             }
1024         )
1025 
1026         launchAnimation.play(progressUpdater)
1027         launchAnimation.start()
1028     }
1029 
1030     /**
1031      * If we are launching split screen without any special animation from a starting View, we
1032      * simply fade in the starting apps and fade out launcher.
1033      */
1034     @VisibleForTesting
1035     fun composeFadeInSplitLaunchAnimator(
1036         initialTaskId: Int,
1037         secondTaskId: Int,
1038         transitionInfo: TransitionInfo,
1039         t: Transaction,
1040         finishCallback: Runnable
1041     ) {
1042         var splitRoot1: Change? = null
1043         var splitRoot2: Change? = null
1044         val openingTargets = ArrayList<SurfaceControl>()
1045         for (change in transitionInfo.changes) {
1046             val taskInfo: RunningTaskInfo = change.taskInfo ?: continue
1047             val taskId = taskInfo.taskId
1048             val mode = change.mode
1049 
1050             // Find the target tasks' root tasks since those are the split stages that need to
1051             // be animated (the tasks themselves are children and thus inherit animation).
1052             if (taskId == initialTaskId || taskId == secondTaskId) {
1053                 check(mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT) {
1054                     "Expected task to be showing, but it is $mode"
1055                 }
1056             }
1057 
1058             if (taskId == initialTaskId) {
1059                 splitRoot1 = change
1060                 val parentToken1 = change.parent
1061                 if (parentToken1 != null) {
1062                     splitRoot1 = transitionInfo.getChange(parentToken1) ?: change
1063                 }
1064 
1065                 if (splitRoot1?.leash != null) {
1066                     openingTargets.add(splitRoot1.leash)
1067                 }
1068             }
1069 
1070             if (taskId == secondTaskId) {
1071                 splitRoot2 = change
1072                 val parentToken2 = change.parent
1073                 if (parentToken2 != null) {
1074                     splitRoot2 = transitionInfo.getChange(parentToken2) ?: change
1075                 }
1076 
1077                 if (splitRoot2?.leash != null) {
1078                     openingTargets.add(splitRoot2.leash)
1079                 }
1080             }
1081         }
1082 
1083         if (splitRoot1 != null) {
1084             // Set the highest level split root alpha; we could technically use the parent of
1085             // either splitRoot1 or splitRoot2
1086             val parentToken = splitRoot1.parent
1087             var rootLayer: Change? = null
1088             if (parentToken != null) {
1089                 rootLayer = transitionInfo.getChange(parentToken)
1090             }
1091             if (rootLayer != null && rootLayer.leash != null) {
1092                 openingTargets.add(rootLayer.leash)
1093             }
1094         }
1095 
1096         val animTransaction = Transaction()
1097         val animator = ValueAnimator.ofFloat(0f, 1f)
1098         animator.setDuration(QuickstepTransitionManager.SPLIT_LAUNCH_DURATION.toLong())
1099         animator.addUpdateListener { valueAnimator: ValueAnimator ->
1100             val progress =
1101                 Interpolators.clampToProgress(
1102                     Interpolators.LINEAR,
1103                     valueAnimator.animatedFraction,
1104                     0.8f,
1105                     1f
1106                 )
1107             for (leash in openingTargets) {
1108                 animTransaction.setAlpha(leash, progress)
1109             }
1110             animTransaction.apply()
1111         }
1112 
1113         animator.addListener(
1114             object : AnimatorListenerAdapter() {
1115                 override fun onAnimationStart(animation: Animator) {
1116                     for (leash in openingTargets) {
1117                         animTransaction.show(leash).setAlpha(leash, 0.0f)
1118                     }
1119                     animTransaction.apply()
1120                 }
1121 
1122                 override fun onAnimationEnd(animation: Animator) {
1123                     finishCallback.run()
1124                 }
1125             }
1126         )
1127 
1128         t.apply()
1129         animator.start()
1130     }
1131 
1132     private fun safeRemoveViewFromDragLayer(container: RecentsViewContainer, view: View?) {
1133         if (view != null) {
1134             container.dragLayer.removeView(view)
1135         }
1136     }
1137 }
1138