Merge "Announce “Desktop” when focusing on the desktop mode in Overview" into main
diff --git a/Android.bp b/Android.bp
index ba04bb3..def024e 100644
--- a/Android.bp
+++ b/Android.bp
@@ -231,6 +231,7 @@
         "androidx.preference_preference",
         "SystemUISharedLib",
         "//frameworks/libs/systemui:animationlib",
+        "//frameworks/libs/systemui:contextualeducationlib",
         "launcher-testing-shared",
     ],
     srcs: [
@@ -311,6 +312,7 @@
         "//frameworks/libs/systemui:iconloader_base",
         "//frameworks/libs/systemui:view_capture",
         "//frameworks/libs/systemui:animationlib",
+        "//frameworks/libs/systemui:contextualeducationlib",
         "SystemUI-statsd",
         "launcher-testing-shared",
         "androidx.lifecycle_lifecycle-common-java8",
diff --git a/quickstep/res/values/config.xml b/quickstep/res/values/config.xml
index fd12210..e8cb5d5 100644
--- a/quickstep/res/values/config.xml
+++ b/quickstep/res/values/config.xml
@@ -35,7 +35,7 @@
     <string name="launcher_restore_event_logger_class" translatable="false">com.android.quickstep.LauncherRestoreEventLoggerImpl</string>
     <string name="plugin_manager_wrapper_class" translatable="false">com.android.launcher3.uioverrides.plugins.PluginManagerWrapperImpl</string>
     <string name="taskbar_edu_tooltip_controller_class" translatable="false">com.android.launcher3.taskbar.TaskbarEduTooltipController</string>
-
+    <string name="contextual_edu_manager_class" translatable="false">com.android.quickstep.contextualeducation.SystemContextualEduStatsManager</string>
     <string name="nav_handle_long_press_handler_class" translatable="false"></string>
     <string name="assist_utils_class" translatable="false"></string>
     <string name="assist_state_manager_class" translatable="false"></string>
diff --git a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
index fe1b403..4a1035f 100644
--- a/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
+++ b/quickstep/src/com/android/launcher3/QuickstepTransitionManager.java
@@ -356,14 +356,6 @@
         options.setOnAnimationAbortListener(endCallback);
         options.setOnAnimationFinishedListener(endCallback);
 
-        // Prepare taskbar for animation synchronization. This needs to happen here before any
-        // app transition is created.
-        LauncherTaskbarUIController taskbarController = mLauncher.getTaskbarUIController();
-        if (enableScalingRevealHomeAnimation() && taskbarController != null) {
-            taskbarController.setIgnoreInAppFlagForSync(true);
-            onEndCallback.add(() -> taskbarController.setIgnoreInAppFlagForSync(false));
-        }
-
         IBinder cookie = mAppLaunchRunner.supportsReturnTransition()
                 ? ((ContainerAnimationRunner) mAppLaunchRunner).getCookie() : null;
         addLaunchCookie(cookie, (ItemInfo) v.getTag(), options);
@@ -1942,21 +1934,6 @@
                 anim.addListener(mForceInvisibleListener);
             }
 
-            // Syncs the app launch animation and taskbar stash animation (if exists).
-            if (enableScalingRevealHomeAnimation()) {
-                LauncherTaskbarUIController taskbarController = mLauncher.getTaskbarUIController();
-                if (taskbarController != null) {
-                    taskbarController.setIgnoreInAppFlagForSync(false);
-
-                    if (launcherClosing) {
-                        Animator taskbar = taskbarController.createAnimToApp();
-                        if (taskbar != null) {
-                            anim.play(taskbar);
-                        }
-                    }
-                }
-            }
-
             result.setAnimation(anim, mLauncher, mOnEndCallback::executeAllAndDestroy,
                     skipFirstFrame);
         }
diff --git a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchView.java b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchView.java
index 0ba5de1..dbd9c73 100644
--- a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchView.java
@@ -54,6 +54,7 @@
 import com.android.launcher3.testing.shared.TestProtocol;
 import com.android.quickstep.util.DesktopTask;
 import com.android.quickstep.util.GroupTask;
+import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.system.InteractionJankMonitorWrapper;
 
 import java.util.HashMap;
@@ -213,9 +214,16 @@
                         resources.getString(R.string.quick_switch_desktop),
                         Locale.getDefault()).format(args));
             } else {
+                final boolean firstTaskIsLeftTopTask =
+                        groupTask.mSplitBounds == null
+                        || groupTask.mSplitBounds.leftTopTaskId == groupTask.task1.key.id;
+                final Task leftTopTask = firstTaskIsLeftTopTask
+                        ? groupTask.task1 : groupTask.task2;
+                final Task rightBottomTask = firstTaskIsLeftTopTask
+                        ? groupTask.task2 : groupTask.task1;
                 currentTaskView.setThumbnails(
-                        groupTask.task1,
-                        groupTask.task2,
+                        leftTopTask,
+                        rightBottomTask,
                         updateTasks ? mViewCallbacks::updateThumbnailInBackground : null,
                         updateTasks ? mViewCallbacks::updateIconInBackground : null);
             }
diff --git a/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java b/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
index e5edd51..8df2eb8 100644
--- a/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/LauncherTaskbarUIController.java
@@ -19,7 +19,6 @@
 import static com.android.launcher3.statemanager.BaseState.FLAG_NON_INTERACTIVE;
 import static com.android.launcher3.taskbar.TaskbarEduTooltipControllerKt.TOOLTIP_STEP_FEATURES;
 import static com.android.launcher3.taskbar.TaskbarLauncherStateController.FLAG_VISIBLE;
-import static com.android.launcher3.taskbar.TaskbarStashController.FLAG_IGNORE_IN_APP;
 import static com.android.quickstep.TaskAnimationManager.ENABLE_SHELL_TRANSITIONS;
 import static com.android.wm.shell.shared.desktopmode.DesktopModeFlags.WALLPAPER_ACTIVITY;
 
@@ -279,28 +278,6 @@
         return mTaskbarLauncherStateController.createAnimToLauncher(toState, callbacks, duration);
     }
 
-    /**
-     * Create Taskbar animation to be played alongside the Launcher app launch animation.
-     */
-    public @Nullable Animator createAnimToApp() {
-        TaskbarStashController stashController = mControllers.taskbarStashController;
-        stashController.updateStateForFlag(TaskbarStashController.FLAG_IN_APP, true);
-        return stashController.createApplyStateAnimator(stashController.getStashDuration());
-    }
-
-    /**
-     * Temporarily ignore FLAG_IN_APP for app launches to prevent premature taskbar stashing.
-     * This is needed because taskbar gets a signal to stash before we actually start the
-     * app launch animation.
-     */
-    public void setIgnoreInAppFlagForSync(boolean enabled) {
-        if (mControllers == null) {
-            // This method can be called before init() is called.
-            return;
-        }
-        mControllers.taskbarStashController.updateStateForFlag(FLAG_IGNORE_IN_APP, enabled);
-    }
-
     public void updateTaskbarLauncherStateGoingHome() {
         mTaskbarLauncherStateController.updateStateForFlag(FLAG_VISIBLE, true);
         mTaskbarLauncherStateController.applyState();
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
index e33e293..8a20131 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarStashController.java
@@ -96,7 +96,6 @@
     public static final int FLAG_STASHED_SYSUI = 1 << 9; //  app pinning,...
     public static final int FLAG_STASHED_DEVICE_LOCKED = 1 << 10; // device is locked: keyguard, ...
     public static final int FLAG_IN_OVERVIEW = 1 << 11; // launcher is in overview
-    public static final int FLAG_IGNORE_IN_APP = 1 << 12; // used to sync with app launch animation
 
     // If any of these flags are enabled, isInApp should return true.
     private static final int FLAGS_IN_APP = FLAG_IN_APP | FLAG_IN_SETUP;
@@ -1285,11 +1284,6 @@
          */
         @Nullable
         public Animator createSetStateAnimator(long flags, long duration) {
-            // We do this when we want to synchronize the app launch and taskbar stash animations.
-            if (hasAnyFlag(FLAG_IGNORE_IN_APP) && hasAnyFlag(flags, FLAG_IN_APP)) {
-                flags = flags & ~FLAG_IN_APP;
-            }
-
             boolean isStashed = mStashCondition.test(flags);
 
             if (DEBUG) {
diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
index 6085998..c3d3729 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java
@@ -1388,10 +1388,11 @@
      */
     public void launchSplitTasks(
             @NonNull GroupTask groupTask, @Nullable RemoteTransition remoteTransition) {
-        // Top/left and bottom/right tasks respectively.
-        Task task1 = groupTask.task1;
+        // SplitBounds can be null if coming from Taskbar launch.
+        final boolean firstTaskIsLeftTopTask = isFirstTaskLeftTopTask(groupTask);
         // task2 should never be null when calling this method. Allow a crash to catch invalid calls
-        Task task2 = groupTask.task2;
+        Task task1 = firstTaskIsLeftTopTask ? groupTask.task1 : groupTask.task2;
+        Task task2 = firstTaskIsLeftTopTask ? groupTask.task2 : groupTask.task1;
         mSplitSelectStateController.launchExistingSplitPair(
                 null /* launchingTaskView */,
                 task1.key.id,
@@ -1405,6 +1406,11 @@
                 remoteTransition);
     }
 
+    private static boolean isFirstTaskLeftTopTask(@NonNull GroupTask groupTask) {
+        return groupTask.mSplitBounds == null
+                || groupTask.mSplitBounds.leftTopTaskId == groupTask.task1.key.id;
+    }
+
     /**
      * Launches two apps as an app pair.
      */
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/StatusBarTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/StatusBarTouchController.java
index d98e608..cb2c324 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/StatusBarTouchController.java
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/StatusBarTouchController.java
@@ -58,8 +58,6 @@
     /* If {@code false}, this controller should not handle the input {@link MotionEvent}.*/
     private boolean mCanIntercept;
 
-    private boolean mIsTrackpadReverseScroll;
-
     public StatusBarTouchController(Launcher l) {
         mLauncher = l;
         mSystemUiProxy = SystemUiProxy.INSTANCE.get(mLauncher);
@@ -95,8 +93,6 @@
             }
             mDownEvents.clear();
             mDownEvents.put(pid, new PointF(ev.getX(), ev.getY()));
-            mIsTrackpadReverseScroll = !mLauncher.isNaturalScrollingEnabled()
-                    && isTrackpadScroll(ev);
         } else if (ev.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN) {
             // Check!! should only set it only when threshold is not entered.
             mDownEvents.put(pid, new PointF(ev.getX(idx), ev.getY(idx)));
@@ -107,9 +103,6 @@
         if (action == ACTION_MOVE && mDownEvents.contains(pid)) {
             float dy = ev.getY(idx) - mDownEvents.get(pid).y;
             float dx = ev.getX(idx) - mDownEvents.get(pid).x;
-            if (mIsTrackpadReverseScroll) {
-                dy = -dy;
-            }
             // Currently input dispatcher will not do touch transfer if there are more than
             // one touch pointer. Hence, even if slope passed, only set the slippery flag
             // when there is single touch event. (context: InputDispatcher.cpp line 1445)
@@ -134,7 +127,6 @@
             mLauncher.getStatsLogManager().logger()
                     .log(LAUNCHER_SWIPE_DOWN_WORKSPACE_NOTISHADE_OPEN);
             setWindowSlippery(false);
-            mIsTrackpadReverseScroll = false;
             return true;
         }
         return true;
@@ -161,9 +153,9 @@
     }
 
     private boolean canInterceptTouch(MotionEvent ev) {
-        if (!mLauncher.isInState(LauncherState.NORMAL) ||
-                AbstractFloatingView.getTopOpenViewWithType(mLauncher,
-                        AbstractFloatingView.TYPE_STATUS_BAR_SWIPE_DOWN_DISALLOW) != null) {
+        if (isTrackpadScroll(ev) || !mLauncher.isInState(LauncherState.NORMAL)
+                || AbstractFloatingView.getTopOpenViewWithType(mLauncher,
+                AbstractFloatingView.TYPE_STATUS_BAR_SWIPE_DOWN_DISALLOW) != null) {
             return false;
         } else {
             // For NORMAL state, only listen if the event originated above the navbar height
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index 4dc04e7..540ab37 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -105,6 +105,7 @@
 import com.android.launcher3.Utilities;
 import com.android.launcher3.anim.AnimationSuccessListener;
 import com.android.launcher3.anim.AnimatorPlaybackController;
+import com.android.launcher3.contextualeducation.ContextualEduStatsManager;
 import com.android.launcher3.dragndrop.DragView;
 import com.android.launcher3.logging.StatsLogManager;
 import com.android.launcher3.logging.StatsLogManager.StatsLogger;
@@ -140,6 +141,7 @@
 import com.android.quickstep.views.RecentsViewContainer;
 import com.android.quickstep.views.TaskContainer;
 import com.android.quickstep.views.TaskView;
+import com.android.systemui.contextualeducation.GestureType;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.recents.model.ThumbnailData;
 import com.android.systemui.shared.system.ActivityManagerWrapper;
@@ -1408,6 +1410,8 @@
             if (mRecentsAnimationController != null) {
                 mRecentsAnimationController.detachNavigationBarFromApp(true);
             }
+            ContextualEduStatsManager.INSTANCE.get(mContext).updateEduStats(
+                    mGestureState.isTrackpadGesture(), GestureType.HOME);
         } else if (endTarget == RECENTS) {
             if (mRecentsView != null) {
                 int nearestPage = mRecentsView.getDestinationPage();
@@ -1432,6 +1436,8 @@
                 if (!mGestureState.isHandlingAtomicEvent() || isScrolling) {
                     duration = Math.max(duration, mRecentsView.getScroller().getDuration());
                 }
+                ContextualEduStatsManager.INSTANCE.get(mContext).updateEduStats(
+                        mGestureState.isTrackpadGesture(), GestureType.OVERVIEW);
             }
         } else if (endTarget == LAST_TASK && mRecentsView != null
                 && mRecentsView.getNextPage() != mRecentsView.getRunningTaskIndex()) {
diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java
index 59e9f05..40e40b0 100644
--- a/quickstep/src/com/android/quickstep/SystemUiProxy.java
+++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java
@@ -239,6 +239,17 @@
     }
 
     @Override
+    public void updateContextualEduStats(boolean isTrackpadGesture, String gestureType) {
+        if (mSystemUiProxy != null) {
+            try {
+                mSystemUiProxy.updateContextualEduStats(isTrackpadGesture, gestureType);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Failed call updateContextualEduStats");
+            }
+        }
+    }
+
+    @Override
     public void setHomeRotationEnabled(boolean enabled) {
         if (mSystemUiProxy != null) {
             try {
diff --git a/quickstep/src/com/android/quickstep/contextualeducation/SystemContextualEduStatsManager.java b/quickstep/src/com/android/quickstep/contextualeducation/SystemContextualEduStatsManager.java
new file mode 100644
index 0000000..d470b88
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/contextualeducation/SystemContextualEduStatsManager.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep.contextualeducation;
+
+import android.content.Context;
+
+import com.android.launcher3.contextualeducation.ContextualEduStatsManager;
+import com.android.quickstep.SystemUiProxy;
+import com.android.systemui.contextualeducation.GestureType;
+
+/**
+ * A class to update contextual education data via {@link SystemUiProxy}
+ */
+public class SystemContextualEduStatsManager extends ContextualEduStatsManager {
+    private Context mContext;
+
+    public SystemContextualEduStatsManager(Context context) {
+        mContext = context;
+    }
+
+    @Override
+    public void updateEduStats(boolean isTrackpadGesture, GestureType gestureType) {
+        SystemUiProxy.INSTANCE.get(mContext).updateContextualEduStats(isTrackpadGesture,
+                gestureType.name());
+    }
+
+    @Override
+    public void close() {
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt b/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
index 6acc940..6f9d157 100644
--- a/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
+++ b/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
@@ -17,65 +17,104 @@
 package com.android.quickstep.recents.data
 
 import android.graphics.drawable.Drawable
+import com.android.launcher3.util.coroutines.DispatcherProvider
 import com.android.quickstep.task.thumbnail.data.TaskIconDataSource
 import com.android.quickstep.task.thumbnail.data.TaskThumbnailDataSource
 import com.android.quickstep.util.GroupTask
 import com.android.systemui.shared.recents.model.Task
 import com.android.systemui.shared.recents.model.ThumbnailData
 import kotlin.coroutines.resume
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChangedBy
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class TasksRepository(
     private val recentsModel: RecentTasksDataSource,
     private val taskThumbnailDataSource: TaskThumbnailDataSource,
     private val taskIconDataSource: TaskIconDataSource,
+    recentsCoroutineScope: CoroutineScope,
+    private val dispatcherProvider: DispatcherProvider,
 ) : RecentTasksRepository {
     private val groupedTaskData = MutableStateFlow(emptyList<GroupTask>())
-    private val _taskData =
-        groupedTaskData.map { groupTaskList -> groupTaskList.flatMap { it.tasks } }
     private val visibleTaskIds = MutableStateFlow(emptySet<Int>())
     private val thumbnailOverride = MutableStateFlow(mapOf<Int, ThumbnailData>())
 
-    private val taskData: Flow<List<Task>> =
-        combine(_taskData, getThumbnailQueryResults(), getIconQueryResults(), thumbnailOverride) {
-            tasks,
-            thumbnailQueryResults,
-            iconQueryResults,
-            thumbnailOverride ->
-            tasks.forEach { task ->
-                // Add retrieved thumbnails + remove unnecessary thumbnails (e.g. invisible)
-                task.thumbnail =
-                    thumbnailOverride[task.key.id] ?: thumbnailQueryResults[task.key.id]
-
-                // TODO(b/352331675) don't load icons for DesktopTaskView
-                // Add retrieved icons + remove unnecessary icons
-                task.icon = iconQueryResults[task.key.id]?.icon
-                task.titleDescription = iconQueryResults[task.key.id]?.contentDescription
-                task.title = iconQueryResults[task.key.id]?.title
-            }
-            tasks
+    private val taskData =
+        groupedTaskData.map { groupTaskList -> groupTaskList.flatMap { it.tasks } }
+    private val visibleTasks =
+        combine(taskData, visibleTaskIds) { tasks, visibleIds ->
+            tasks.filter { it.key.id in visibleIds }
         }
 
+    private val iconQueryResults: Flow<Map<Int, TaskIconQueryResponse?>> =
+        visibleTasks
+            .map { visibleTasksList -> visibleTasksList.map(::getIconDataRequest) }
+            .flatMapLatest { iconRequestFlows: List<IconDataRequest> ->
+                if (iconRequestFlows.isEmpty()) {
+                    flowOf(emptyMap())
+                } else {
+                    combine(iconRequestFlows) { it.toMap() }
+                }
+            }
+            .distinctUntilChanged()
+
+    private val thumbnailQueryResults: Flow<Map<Int, ThumbnailData?>> =
+        visibleTasks
+            .map { visibleTasksList -> visibleTasksList.map(::getThumbnailDataRequest) }
+            .flatMapLatest { thumbnailRequestFlows: List<ThumbnailDataRequest> ->
+                if (thumbnailRequestFlows.isEmpty()) {
+                    flowOf(emptyMap())
+                } else {
+                    combine(thumbnailRequestFlows) { it.toMap() }
+                }
+            }
+            .distinctUntilChanged()
+
+    private val augmentedTaskData: Flow<List<Task>> =
+        combine(taskData, thumbnailQueryResults, iconQueryResults, thumbnailOverride) {
+                tasks,
+                thumbnailQueryResults,
+                iconQueryResults,
+                thumbnailOverride ->
+                tasks.onEach { task ->
+                    // Add retrieved thumbnails + remove unnecessary thumbnails (e.g. invisible)
+                    task.thumbnail =
+                        thumbnailOverride[task.key.id] ?: thumbnailQueryResults[task.key.id]
+
+                    // TODO(b/352331675) don't load icons for DesktopTaskView
+                    // Add retrieved icons + remove unnecessary icons
+                    val iconQueryResult = iconQueryResults[task.key.id]
+                    task.icon = iconQueryResult?.icon
+                    task.titleDescription = iconQueryResult?.contentDescription
+                    task.title = iconQueryResult?.title
+                }
+            }
+            .flowOn(dispatcherProvider.io)
+            .shareIn(recentsCoroutineScope, SharingStarted.WhileSubscribed(), replay = 1)
+
     override fun getAllTaskData(forceRefresh: Boolean): Flow<List<Task>> {
         if (forceRefresh) {
             recentsModel.getTasks { groupedTaskData.value = it }
         }
-        return taskData
+        return augmentedTaskData
     }
 
     override fun getTaskDataById(taskId: Int): Flow<Task?> =
-        taskData.map { taskList -> taskList.firstOrNull { it.key.id == taskId } }
+        augmentedTaskData.map { taskList -> taskList.firstOrNull { it.key.id == taskId } }
 
     override fun getThumbnailById(taskId: Int): Flow<ThumbnailData?> =
         getTaskDataById(taskId).map { it?.thumbnail }.distinctUntilChangedBy { it?.snapshotId }
@@ -94,41 +133,19 @@
     }
 
     /** Flow wrapper for [TaskThumbnailDataSource.getThumbnailInBackground] api */
-    private fun getThumbnailDataRequest(task: Task): ThumbnailDataRequest =
-        flow {
-                emit(task.key.id to task.thumbnail)
-                val thumbnailDataResult: ThumbnailData? =
-                    suspendCancellableCoroutine { continuation ->
-                        val cancellableTask =
-                            taskThumbnailDataSource.getThumbnailInBackground(task) {
-                                continuation.resume(it)
-                            }
-                        continuation.invokeOnCancellation { cancellableTask?.cancel() }
-                    }
-                emit(task.key.id to thumbnailDataResult)
+    private fun getThumbnailDataRequest(task: Task): ThumbnailDataRequest = flow {
+        emit(task.key.id to task.thumbnail)
+        val thumbnailDataResult: ThumbnailData? =
+            withContext(dispatcherProvider.main) {
+                suspendCancellableCoroutine { continuation ->
+                    val cancellableTask =
+                        taskThumbnailDataSource.getThumbnailInBackground(task) {
+                            continuation.resume(it)
+                        }
+                    continuation.invokeOnCancellation { cancellableTask?.cancel() }
+                }
             }
-            .distinctUntilChanged()
-
-    /**
-     * This is a Flow that makes a query for thumbnail data to the [taskThumbnailDataSource] for
-     * each visible task. It then collects the responses and returns them in a Map as soon as they
-     * are available.
-     */
-    private fun getThumbnailQueryResults(): Flow<Map<Int, ThumbnailData?>> {
-        val visibleTasks =
-            combine(_taskData, visibleTaskIds) { tasks, visibleIds ->
-                tasks.filter { it.key.id in visibleIds }
-            }
-        val visibleThumbnailDataRequests: Flow<List<ThumbnailDataRequest>> =
-            visibleTasks.map { visibleTasksList -> visibleTasksList.map(::getThumbnailDataRequest) }
-        return visibleThumbnailDataRequests.flatMapLatest {
-            thumbnailRequestFlows: List<ThumbnailDataRequest> ->
-            if (thumbnailRequestFlows.isEmpty()) {
-                flowOf(emptyMap())
-            } else {
-                combine(thumbnailRequestFlows) { it.toMap() }
-            }
-        }
+        emit(task.key.id to thumbnailDataResult)
     }
 
     /** Flow wrapper for [TaskThumbnailDataSource.getThumbnailInBackground] api */
@@ -136,43 +153,29 @@
         flow {
                 emit(task.key.id to task.getTaskIconQueryResponse())
                 val iconDataResponse: TaskIconQueryResponse? =
-                    suspendCancellableCoroutine { continuation ->
-                        val cancellableTask =
-                            taskIconDataSource.getIconInBackground(task) {
-                                icon,
-                                contentDescription,
-                                title ->
-                                icon.constantState?.let {
-                                    continuation.resume(
-                                        TaskIconQueryResponse(
-                                            it.newDrawable().mutate(),
-                                            contentDescription,
-                                            title
+                    withContext(dispatcherProvider.main) {
+                        suspendCancellableCoroutine { continuation ->
+                            val cancellableTask =
+                                taskIconDataSource.getIconInBackground(task) {
+                                    icon,
+                                    contentDescription,
+                                    title ->
+                                    icon.constantState?.let {
+                                        continuation.resume(
+                                            TaskIconQueryResponse(
+                                                it.newDrawable().mutate(),
+                                                contentDescription,
+                                                title
+                                            )
                                         )
-                                    )
+                                    }
                                 }
-                            }
-                        continuation.invokeOnCancellation { cancellableTask?.cancel() }
+                            continuation.invokeOnCancellation { cancellableTask?.cancel() }
+                        }
                     }
                 emit(task.key.id to iconDataResponse)
             }
             .distinctUntilChanged()
-
-    private fun getIconQueryResults(): Flow<Map<Int, TaskIconQueryResponse?>> {
-        val visibleTasks =
-            combine(_taskData, visibleTaskIds) { tasks, visibleIds ->
-                tasks.filter { it.key.id in visibleIds }
-            }
-        val visibleIconDataRequests: Flow<List<IconDataRequest>> =
-            visibleTasks.map { visibleTasksList -> visibleTasksList.map(::getIconDataRequest) }
-        return visibleIconDataRequests.flatMapLatest { iconRequestFlows: List<IconDataRequest> ->
-            if (iconRequestFlows.isEmpty()) {
-                flowOf(emptyMap())
-            } else {
-                combine(iconRequestFlows) { it.toMap() }
-            }
-        }
-    }
 }
 
 data class TaskIconQueryResponse(
diff --git a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
index eba7688..d8156b1 100644
--- a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
+++ b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
@@ -19,6 +19,7 @@
 import android.content.Context
 import android.util.Log
 import android.view.View
+import com.android.launcher3.util.coroutines.ProductionDispatchers
 import com.android.quickstep.RecentsModel
 import com.android.quickstep.recents.data.RecentTasksRepository
 import com.android.quickstep.recents.data.TasksRepository
@@ -36,6 +37,10 @@
 import com.android.quickstep.task.viewmodel.TaskViewModel
 import com.android.quickstep.views.TaskViewType
 import com.android.systemui.shared.recents.model.Task
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
 
 internal typealias RecentsScopeId = String
 
@@ -53,11 +58,20 @@
     private fun startDefaultScope(appContext: Context) {
         createScope(DEFAULT_SCOPE_ID).apply {
             set(RecentsViewData::class.java.simpleName, RecentsViewData())
+            val recentsCoroutineScope =
+                CoroutineScope(SupervisorJob() + Dispatchers.Main + CoroutineName("RecentsView"))
+            set(CoroutineScope::class.java.simpleName, recentsCoroutineScope)
 
             // Create RecentsTaskRepository singleton
             val recentTasksRepository: RecentTasksRepository =
                 with(RecentsModel.INSTANCE.get(appContext)) {
-                    TasksRepository(this, thumbnailCache, iconCache)
+                    TasksRepository(
+                        this,
+                        thumbnailCache,
+                        iconCache,
+                        recentsCoroutineScope,
+                        ProductionDispatchers
+                    )
                 }
             set(RecentTasksRepository::class.java.simpleName, recentTasksRepository)
         }
@@ -137,7 +151,13 @@
             when (modelClass) {
                 RecentTasksRepository::class.java -> {
                     with(RecentsModel.INSTANCE.get(appContext)) {
-                        TasksRepository(this, thumbnailCache, iconCache)
+                        TasksRepository(
+                            this,
+                            thumbnailCache,
+                            iconCache,
+                            get(),
+                            ProductionDispatchers
+                        )
                     }
                 }
                 RecentsViewData::class.java -> RecentsViewData()
diff --git a/quickstep/src/com/android/quickstep/util/AssistContentRequester.java b/quickstep/src/com/android/quickstep/util/AssistContentRequester.java
index 0ce54ad..2e3dee6 100644
--- a/quickstep/src/com/android/quickstep/util/AssistContentRequester.java
+++ b/quickstep/src/com/android/quickstep/util/AssistContentRequester.java
@@ -81,7 +81,7 @@
             try {
                 mActivityTaskManager.requestAssistDataForTask(
                         new AssistDataReceiver(callback, this), taskId, mPackageName,
-                        mAttributionTag);
+                        mAttributionTag, false /* fetchStructure */);
             } catch (RemoteException e) {
                 Log.e(TAG, "Requesting assist content failed for task: " + taskId, e);
             }
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/LauncherSwipeHandlerV2Test.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/LauncherSwipeHandlerV2Test.kt
new file mode 100644
index 0000000..1f88743
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/LauncherSwipeHandlerV2Test.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.quickstep
+
+import android.graphics.PointF
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.launcher3.R
+import com.android.launcher3.util.LauncherModelHelper
+import com.android.systemui.contextualeducation.GestureType
+import com.android.systemui.shared.system.InputConsumerController
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
+import org.mockito.junit.MockitoJUnit
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class LauncherSwipeHandlerV2Test {
+
+    @Mock private lateinit var taskAnimationManager: TaskAnimationManager
+
+    private lateinit var gestureState: GestureState
+    @Mock private lateinit var inputConsumerController: InputConsumerController
+
+    @Mock private lateinit var systemUiProxy: SystemUiProxy
+
+    private lateinit var underTest: LauncherSwipeHandlerV2
+
+    @get:Rule val mockitoRule = MockitoJUnit.rule()
+
+    private val launcherModelHelper = LauncherModelHelper()
+    private val sandboxContext = launcherModelHelper.sandboxContext
+
+    private val flingSpeed =
+        -(sandboxContext.resources.getDimension(R.dimen.quickstep_fling_threshold_speed) + 1)
+
+    @Before
+    fun setup() {
+        sandboxContext.putObject(SystemUiProxy.INSTANCE, systemUiProxy)
+        val deviceState = mock(RecentsAnimationDeviceState::class.java)
+        whenever(deviceState.rotationTouchHelper).thenReturn(mock(RotationTouchHelper::class.java))
+        gestureState = spy(GestureState(OverviewComponentObserver(sandboxContext, deviceState), 0))
+
+        underTest =
+            LauncherSwipeHandlerV2(
+                sandboxContext,
+                deviceState,
+                taskAnimationManager,
+                gestureState,
+                0,
+                false,
+                inputConsumerController
+            )
+        underTest.onGestureStarted(/* isLikelyToStartNewTask= */ false)
+    }
+
+    @Test
+    fun goHomeFromAppByTrackpad_updateEduStats() {
+        gestureState.setTrackpadGestureType(GestureState.TrackpadGestureType.THREE_FINGER)
+        underTest.onGestureEnded(flingSpeed, PointF())
+        verify(systemUiProxy)
+            .updateContextualEduStats(
+                /* isTrackpadGesture= */ eq(true),
+                eq(GestureType.HOME.toString())
+            )
+    }
+
+    @Test
+    fun goHomeFromAppByTouch_updateEduStats() {
+        underTest.onGestureEnded(flingSpeed, PointF())
+        verify(systemUiProxy)
+            .updateContextualEduStats(
+                /* isTrackpadGesture= */ eq(false),
+                eq(GestureType.HOME.toString())
+            )
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/TasksRepositoryTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/TasksRepositoryTest.kt
index b34e156..e6534eb 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/TasksRepositoryTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/TasksRepositoryTest.kt
@@ -20,6 +20,7 @@
 import android.content.Intent
 import android.graphics.Bitmap
 import android.view.Surface
+import com.android.launcher3.util.TestDispatcherProvider
 import com.android.quickstep.task.thumbnail.TaskThumbnailViewModelTest
 import com.android.quickstep.util.DesktopTask
 import com.android.quickstep.util.GroupTask
@@ -27,10 +28,8 @@
 import com.android.systemui.shared.recents.model.ThumbnailData
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
@@ -50,223 +49,232 @@
     private val taskThumbnailDataSource = FakeTaskThumbnailDataSource()
     private val taskIconDataSource = FakeTaskIconDataSource()
 
+    private val dispatcher = UnconfinedTestDispatcher()
+    private val testScope = TestScope(dispatcher)
     private val systemUnderTest =
-        TasksRepository(recentsModel, taskThumbnailDataSource, taskIconDataSource)
+        TasksRepository(
+            recentsModel,
+            taskThumbnailDataSource,
+            taskIconDataSource,
+            testScope.backgroundScope,
+            TestDispatcherProvider(dispatcher)
+        )
 
     @Test
-    fun getAllTaskDataReturnsFlattenedListOfTasks() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
+    fun getAllTaskDataReturnsFlattenedListOfTasks() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
 
-        assertThat(systemUnderTest.getAllTaskData(forceRefresh = true).first()).isEqualTo(tasks)
-    }
-
-    @Test
-    fun getTaskDataByIdReturnsSpecificTask() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        assertThat(systemUnderTest.getTaskDataById(2).first()).isEqualTo(tasks[2])
-    }
-
-    @Test
-    fun setVisibleTasksPopulatesThumbnails() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        assertThat(systemUnderTest.getTaskDataById(1).drop(1).first()!!.thumbnail!!.thumbnail)
-            .isEqualTo(bitmap1)
-        assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
-            .isEqualTo(bitmap2)
-    }
-
-    @Test
-    fun setVisibleTasksPopulatesIcons() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        systemUnderTest
-            .getTaskDataById(1)
-            .drop(1)
-            .first()!!
-            .assertHasIconDataFromSource(taskIconDataSource)
-        systemUnderTest.getTaskDataById(2).first()!!.assertHasIconDataFromSource(taskIconDataSource)
-    }
-
-    @Test
-    fun changingVisibleTasksContainsAlreadyPopulatedThumbnails() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        assertThat(systemUnderTest.getTaskDataById(2).drop(1).first()!!.thumbnail!!.thumbnail)
-            .isEqualTo(bitmap2)
-
-        // Prevent new loading of Bitmaps
-        taskThumbnailDataSource.shouldLoadSynchronously = false
-        systemUnderTest.setVisibleTasks(listOf(2, 3))
-
-        assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
-            .isEqualTo(bitmap2)
-    }
-
-    @Test
-    fun changingVisibleTasksContainsAlreadyPopulatedIcons() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // .drop(1) to ignore initial null content before from icon was loaded.
-        systemUnderTest
-            .getTaskDataById(2)
-            .drop(1)
-            .first()!!
-            .assertHasIconDataFromSource(taskIconDataSource)
-
-        // Prevent new loading of Drawables
-        taskThumbnailDataSource.shouldLoadSynchronously = false
-        systemUnderTest.setVisibleTasks(listOf(2, 3))
-
-        systemUnderTest.getTaskDataById(2).first()!!.assertHasIconDataFromSource(taskIconDataSource)
-    }
-
-    @Test
-    fun retrievedImagesAreDiscardedWhenTaskBecomesInvisible() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        val task2 = systemUnderTest.getTaskDataById(2).drop(1).first()!!
-        assertThat(task2.thumbnail!!.thumbnail).isEqualTo(bitmap2)
-        task2.assertHasIconDataFromSource(taskIconDataSource)
-
-        // Prevent new loading of Bitmaps
-        taskThumbnailDataSource.shouldLoadSynchronously = false
-        taskIconDataSource.shouldLoadSynchronously = false
-        systemUnderTest.setVisibleTasks(listOf(0, 1))
-
-        val task2AfterVisibleTasksChanged = systemUnderTest.getTaskDataById(2).first()!!
-        assertThat(task2AfterVisibleTasksChanged.thumbnail).isNull()
-        assertThat(task2AfterVisibleTasksChanged.icon).isNull()
-        assertThat(task2AfterVisibleTasksChanged.titleDescription).isNull()
-        assertThat(task2AfterVisibleTasksChanged.title).isNull()
-    }
-
-    @Test
-    fun retrievedThumbnailsCauseEmissionOnTaskDataFlow() = runTest {
-        // Setup fakes
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        taskThumbnailDataSource.shouldLoadSynchronously = false
-
-        // Setup TasksRepository
-        systemUnderTest.getAllTaskData(forceRefresh = true)
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-
-        // Assert there is no bitmap in first emission
-        val taskFlow = systemUnderTest.getTaskDataById(2)
-        val taskFlowValuesList = mutableListOf<Task?>()
-        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
-            taskFlow.toList(taskFlowValuesList)
+            assertThat(systemUnderTest.getAllTaskData(forceRefresh = true).first()).isEqualTo(tasks)
         }
-        assertThat(taskFlowValuesList[0]!!.thumbnail).isNull()
-
-        // Simulate bitmap loading after first emission
-        taskThumbnailDataSource.taskIdToUpdatingTask.getValue(2).invoke()
-
-        // Check for second emission
-        assertThat(taskFlowValuesList[1]!!.thumbnail!!.thumbnail).isEqualTo(bitmap2)
-    }
 
     @Test
-    fun addThumbnailOverrideOverrideThumbnails() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
-        val thumbnailOverride2 = createThumbnailData()
-        systemUnderTest.getAllTaskData(forceRefresh = true)
+    fun getTaskDataByIdReturnsSpecificTask() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            systemUnderTest.getAllTaskData(forceRefresh = true)
 
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
-
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        assertThat(systemUnderTest.getThumbnailById(1).drop(1).first()!!.thumbnail)
-            .isEqualTo(bitmap1)
-        assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail)
-            .isEqualTo(thumbnailOverride2.thumbnail)
-    }
+            assertThat(systemUnderTest.getTaskDataById(2).first()).isEqualTo(tasks[2])
+        }
 
     @Test
-    fun addThumbnailOverrideMultipleOverrides() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val thumbnailOverride1 = createThumbnailData()
-        val thumbnailOverride2 = createThumbnailData()
-        val thumbnailOverride3 = createThumbnailData()
-        systemUnderTest.getAllTaskData(forceRefresh = true)
+    fun setVisibleTasksPopulatesThumbnails() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            systemUnderTest.getAllTaskData(forceRefresh = true)
 
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(1 to thumbnailOverride1))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride3))
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
 
-        assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail)
-            .isEqualTo(thumbnailOverride1.thumbnail)
-        assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail)
-            .isEqualTo(thumbnailOverride3.thumbnail)
-    }
+            assertThat(systemUnderTest.getTaskDataById(1).first()!!.thumbnail!!.thumbnail)
+                .isEqualTo(bitmap1)
+            assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
+                .isEqualTo(bitmap2)
+        }
 
     @Test
-    fun addThumbnailOverrideClearedWhenTaskBecomeInvisible() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        val thumbnailOverride1 = createThumbnailData()
-        val thumbnailOverride2 = createThumbnailData()
-        systemUnderTest.getAllTaskData(forceRefresh = true)
+    fun setVisibleTasksPopulatesIcons() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            systemUnderTest.getAllTaskData(forceRefresh = true)
 
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(1 to thumbnailOverride1))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
-        // Making task 2 invisible and visible again should clear the override
-        systemUnderTest.setVisibleTasks(listOf(1))
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
 
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail)
-            .isEqualTo(thumbnailOverride1.thumbnail)
-        assertThat(systemUnderTest.getThumbnailById(2).drop(1).first()!!.thumbnail)
-            .isEqualTo(bitmap2)
-    }
+            systemUnderTest
+                .getTaskDataById(1)
+                .first()!!
+                .assertHasIconDataFromSource(taskIconDataSource)
+            systemUnderTest
+                .getTaskDataById(2)
+                .first()!!
+                .assertHasIconDataFromSource(taskIconDataSource)
+        }
 
     @Test
-    fun addThumbnailOverrideDoesNotOverrideInvisibleTasks() = runTest {
-        recentsModel.seedTasks(defaultTaskList)
-        val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
-        val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
-        val thumbnailOverride = createThumbnailData()
-        systemUnderTest.getAllTaskData(forceRefresh = true)
+    fun changingVisibleTasksContainsAlreadyPopulatedThumbnails() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            systemUnderTest.getAllTaskData(forceRefresh = true)
 
-        systemUnderTest.setVisibleTasks(listOf(1))
-        systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride))
-        systemUnderTest.setVisibleTasks(listOf(1, 2))
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
 
-        // .drop(1) to ignore initial null content before from thumbnail was loaded.
-        assertThat(systemUnderTest.getThumbnailById(1).drop(1).first()!!.thumbnail)
-            .isEqualTo(bitmap1)
-        assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail).isEqualTo(bitmap2)
-    }
+            assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
+                .isEqualTo(bitmap2)
+
+            // Prevent new loading of Bitmaps
+            taskThumbnailDataSource.shouldLoadSynchronously = false
+            systemUnderTest.setVisibleTasks(listOf(2, 3))
+
+            assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
+                .isEqualTo(bitmap2)
+        }
+
+    @Test
+    fun changingVisibleTasksContainsAlreadyPopulatedIcons() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+
+            systemUnderTest
+                .getTaskDataById(2)
+                .first()!!
+                .assertHasIconDataFromSource(taskIconDataSource)
+
+            // Prevent new loading of Drawables
+            taskThumbnailDataSource.shouldLoadSynchronously = false
+            systemUnderTest.setVisibleTasks(listOf(2, 3))
+
+            systemUnderTest
+                .getTaskDataById(2)
+                .first()!!
+                .assertHasIconDataFromSource(taskIconDataSource)
+        }
+
+    @Test
+    fun retrievedImagesAreDiscardedWhenTaskBecomesInvisible() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+
+            val task2 = systemUnderTest.getTaskDataById(2).first()!!
+            assertThat(task2.thumbnail!!.thumbnail).isEqualTo(bitmap2)
+            task2.assertHasIconDataFromSource(taskIconDataSource)
+
+            // Prevent new loading of Bitmaps
+            taskThumbnailDataSource.shouldLoadSynchronously = false
+            taskIconDataSource.shouldLoadSynchronously = false
+            systemUnderTest.setVisibleTasks(listOf(0, 1))
+
+            val task2AfterVisibleTasksChanged = systemUnderTest.getTaskDataById(2).first()!!
+            assertThat(task2AfterVisibleTasksChanged.thumbnail).isNull()
+            assertThat(task2AfterVisibleTasksChanged.icon).isNull()
+            assertThat(task2AfterVisibleTasksChanged.titleDescription).isNull()
+            assertThat(task2AfterVisibleTasksChanged.title).isNull()
+        }
+
+    @Test
+    fun retrievedThumbnailsCauseEmissionOnTaskDataFlow() =
+        testScope.runTest {
+            // Setup fakes
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            taskThumbnailDataSource.shouldLoadSynchronously = false
+
+            // Setup TasksRepository
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+
+            // Assert there is no bitmap in first emission
+            assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail).isNull()
+
+            // Simulate bitmap loading after first emission
+            taskThumbnailDataSource.taskIdToUpdatingTask.getValue(2).invoke()
+
+            // Check for second emission
+            assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail!!.thumbnail)
+                .isEqualTo(bitmap2)
+        }
+
+    @Test
+    fun addThumbnailOverrideOverrideThumbnails() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
+            val thumbnailOverride2 = createThumbnailData()
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
+
+            assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail).isEqualTo(bitmap1)
+            assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail)
+                .isEqualTo(thumbnailOverride2.thumbnail)
+        }
+
+    @Test
+    fun addThumbnailOverrideMultipleOverrides() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val thumbnailOverride1 = createThumbnailData()
+            val thumbnailOverride2 = createThumbnailData()
+            val thumbnailOverride3 = createThumbnailData()
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(1 to thumbnailOverride1))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride3))
+
+            assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail)
+                .isEqualTo(thumbnailOverride1.thumbnail)
+            assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail)
+                .isEqualTo(thumbnailOverride3.thumbnail)
+        }
+
+    @Test
+    fun addThumbnailOverrideClearedWhenTaskBecomeInvisible() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            val thumbnailOverride1 = createThumbnailData()
+            val thumbnailOverride2 = createThumbnailData()
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(1 to thumbnailOverride1))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride2))
+            // Making task 2 invisible and visible again should clear the override
+            systemUnderTest.setVisibleTasks(listOf(1))
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+
+            assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail)
+                .isEqualTo(thumbnailOverride1.thumbnail)
+            assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail).isEqualTo(bitmap2)
+        }
+
+    @Test
+    fun addThumbnailOverrideDoesNotOverrideInvisibleTasks() =
+        testScope.runTest {
+            recentsModel.seedTasks(defaultTaskList)
+            val bitmap1 = taskThumbnailDataSource.taskIdToBitmap[1]
+            val bitmap2 = taskThumbnailDataSource.taskIdToBitmap[2]
+            val thumbnailOverride = createThumbnailData()
+            systemUnderTest.getAllTaskData(forceRefresh = true)
+
+            systemUnderTest.setVisibleTasks(listOf(1))
+            systemUnderTest.addOrUpdateThumbnailOverride(mapOf(2 to thumbnailOverride))
+            systemUnderTest.setVisibleTasks(listOf(1, 2))
+
+            assertThat(systemUnderTest.getThumbnailById(1).first()!!.thumbnail).isEqualTo(bitmap1)
+            assertThat(systemUnderTest.getThumbnailById(2).first()!!.thumbnail).isEqualTo(bitmap2)
+        }
 
     private fun createTaskWithId(taskId: Int) =
         Task(Task.TaskKey(taskId, 0, Intent(), ComponentName("", ""), 0, 2000))
diff --git a/res/values/config.xml b/res/values/config.xml
index 2a3b588..507ce9a 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -77,6 +77,7 @@
     <string name="taskbar_view_callbacks_factory_class" translatable="false"></string>
     <string name="launcher_restore_event_logger_class" translatable="false"></string>
     <string name="taskbar_edu_tooltip_controller_class" translatable="false"></string>
+    <string name="contextual_edu_manager_class" translatable="false"></string>
     <!--  Used for determining category of a widget presented in widget recommendations. -->
     <string name="widget_recommendation_category_provider_class" translatable="false"></string>
     <string name="api_wrapper_class" translatable="false"></string>
diff --git a/src/com/android/launcher3/contextualeducation/ContextualEduStatsManager.java b/src/com/android/launcher3/contextualeducation/ContextualEduStatsManager.java
new file mode 100644
index 0000000..da13546
--- /dev/null
+++ b/src/com/android/launcher3/contextualeducation/ContextualEduStatsManager.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.contextualeducation;
+
+import static com.android.launcher3.util.MainThreadInitializedObject.forOverride;
+
+import com.android.launcher3.R;
+import com.android.launcher3.util.MainThreadInitializedObject;
+import com.android.launcher3.util.ResourceBasedOverride;
+import com.android.launcher3.util.SafeCloseable;
+import com.android.systemui.contextualeducation.GestureType;
+
+/**
+ * A class to update contextual education data. It is a no-op implementation and could be
+ * overridden by changing the resource value [R.string.contextual_edu_manager_class] to provide
+ * a real implementation.
+ */
+public class ContextualEduStatsManager implements ResourceBasedOverride, SafeCloseable {
+    public static final MainThreadInitializedObject<ContextualEduStatsManager> INSTANCE =
+            forOverride(ContextualEduStatsManager.class, R.string.contextual_edu_manager_class);
+
+    /**
+     * Updates contextual education stats when a gesture is triggered
+     * @param isTrackpadGesture indicates if the gesture is triggered by trackpad
+     * @param gestureType type of gesture triggered
+     */
+    public void updateEduStats(boolean isTrackpadGesture, GestureType gestureType) {
+    }
+
+    @Override
+    public void close() {
+    }
+}
diff --git a/src/com/android/launcher3/recyclerview/AllAppsRecyclerViewPool.kt b/src/com/android/launcher3/recyclerview/AllAppsRecyclerViewPool.kt
index e317824..6ff51ca 100644
--- a/src/com/android/launcher3/recyclerview/AllAppsRecyclerViewPool.kt
+++ b/src/com/android/launcher3/recyclerview/AllAppsRecyclerViewPool.kt
@@ -108,6 +108,11 @@
                         if (task?.canceled == true) {
                             break
                         }
+                        // If activeRv's layout manager has been reset to null on main thread, skip
+                        // the preinflation as we cannot generate correct LayoutParams
+                        if (activeRv.layoutManager == null) {
+                            break
+                        }
                         list.add(
                             adapter.createViewHolder(activeRv, BaseAllAppsAdapter.VIEW_TYPE_ICON)
                         )
diff --git a/src/com/android/launcher3/util/coroutines/DispatcherProvider.kt b/src/com/android/launcher3/util/coroutines/DispatcherProvider.kt
new file mode 100644
index 0000000..e9691a8
--- /dev/null
+++ b/src/com/android/launcher3/util/coroutines/DispatcherProvider.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.util.coroutines
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+interface DispatcherProvider {
+    val default: CoroutineDispatcher
+    val io: CoroutineDispatcher
+    val main: CoroutineDispatcher
+    val unconfined: CoroutineDispatcher
+}
+
+object ProductionDispatchers : DispatcherProvider {
+    override val default: CoroutineDispatcher = Dispatchers.Default
+    override val io: CoroutineDispatcher = Dispatchers.IO
+    override val main: CoroutineDispatcher = Dispatchers.Main
+    override val unconfined: CoroutineDispatcher = Dispatchers.Unconfined
+}
diff --git a/tests/multivalentTests/src/com/android/launcher3/util/TestDispatcherProvider.kt b/tests/multivalentTests/src/com/android/launcher3/util/TestDispatcherProvider.kt
new file mode 100644
index 0000000..39e1ec5
--- /dev/null
+++ b/tests/multivalentTests/src/com/android/launcher3/util/TestDispatcherProvider.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.util
+
+import com.android.launcher3.util.coroutines.DispatcherProvider
+import kotlinx.coroutines.CoroutineDispatcher
+
+class TestDispatcherProvider(testDispatcher: CoroutineDispatcher) : DispatcherProvider {
+    override val default: CoroutineDispatcher = testDispatcher
+    override val io: CoroutineDispatcher = testDispatcher
+    override val main: CoroutineDispatcher = testDispatcher
+    override val unconfined: CoroutineDispatcher = testDispatcher
+}