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