Move RecentsModel to Dagger.

Bug: 361850561
Test: Presubmit
Flag: EXEMPT dagger

Change-Id: I0d459ee3fc012e8e371035071f1b32eb2483cdc5
diff --git a/quickstep/src/com/android/quickstep/RecentsModel.java b/quickstep/src/com/android/quickstep/RecentsModel.java
index 87b58e6..1d83d42 100644
--- a/quickstep/src/com/android/quickstep/RecentsModel.java
+++ b/quickstep/src/com/android/quickstep/RecentsModel.java
@@ -38,13 +38,18 @@
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 
+import com.android.launcher3.dagger.ApplicationContext;
+import com.android.launcher3.dagger.LauncherAppSingleton;
 import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.graphics.ThemeManager.ThemeChangeListener;
 import com.android.launcher3.icons.IconProvider;
+import com.android.launcher3.util.DaggerSingletonObject;
+import com.android.launcher3.util.DaggerSingletonTracker;
+import com.android.launcher3.util.DisplayController;
 import com.android.launcher3.util.Executors.SimpleThreadFactory;
 import com.android.launcher3.util.LockedUserState;
-import com.android.launcher3.util.MainThreadInitializedObject;
 import com.android.launcher3.util.SafeCloseable;
+import com.android.quickstep.dagger.QuickstepBaseAppComponent;
 import com.android.quickstep.recents.data.RecentTasksDataSource;
 import com.android.quickstep.recents.data.TaskVisualsChangeNotifier;
 import com.android.quickstep.util.DesktopTask;
@@ -65,19 +70,22 @@
 import java.util.function.Consumer;
 import java.util.function.Predicate;
 
-import javax.inject.Provider;
+import javax.inject.Inject;
+
+import dagger.Lazy;
 
 /**
  * Singleton class to load and manage recents model.
  */
 @TargetApi(Build.VERSION_CODES.O)
+@LauncherAppSingleton
 public class RecentsModel implements RecentTasksDataSource, TaskStackChangeListener,
         TaskVisualsChangeListener, TaskVisualsChangeNotifier,
-        ThemeChangeListener, SafeCloseable {
+        ThemeChangeListener {
 
     // We do not need any synchronization for this variable as its only written on UI thread.
-    public static final MainThreadInitializedObject<RecentsModel> INSTANCE =
-            new MainThreadInitializedObject<>(RecentsModel::new);
+    public static final DaggerSingletonObject<RecentsModel> INSTANCE =
+            new DaggerSingletonObject<>(QuickstepBaseAppComponent::getRecentsModel);
 
     private static final Executor RECENTS_MODEL_EXECUTOR = Executors.newSingleThreadExecutor(
             new SimpleThreadFactory("TaskThumbnailIconCache-", THREAD_PRIORITY_BACKGROUND));
@@ -85,53 +93,67 @@
     private final ConcurrentLinkedQueue<TaskVisualsChangeListener> mThumbnailChangeListeners =
             new ConcurrentLinkedQueue<>();
     private final Context mContext;
-
     private final RecentTasksList mTaskList;
     private final TaskIconCache mIconCache;
     private final TaskThumbnailCache mThumbnailCache;
-    private final ComponentCallbacks mCallbacks;
 
-    private final TaskStackChangeListeners mTaskStackChangeListeners;
-    private final SafeCloseable mIconChangeCloseable;
-
-    private final LockedUserState mLockedUserState;
-    private final Provider<ThemeManager> mThemeManagerProvider;
-    private final Runnable mUnlockCallback;
-
-    private RecentsModel(Context context) {
-        this(context, new IconProvider(context));
+    @Inject
+     public RecentsModel(@ApplicationContext Context context,
+            SystemUiProxy systemUiProxy,
+            TopTaskTracker topTaskTracker,
+            DisplayController displayController,
+            LockedUserState lockedUserState,
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker
+            ) {
+        // Lazily inject the ThemeManager and access themeManager once the device is
+        // unlocked. See b/393248495 for details.
+        this(context, new IconProvider(context), systemUiProxy, topTaskTracker,
+                displayController, lockedUserState,themeManagerLazy, tracker);
     }
 
     @SuppressLint("VisibleForTests")
-    private RecentsModel(Context context, IconProvider iconProvider) {
+    private RecentsModel(@ApplicationContext Context context,
+            IconProvider iconProvider,
+            SystemUiProxy systemUiProxy,
+            TopTaskTracker topTaskTracker,
+            DisplayController displayController,
+            LockedUserState lockedUserState,
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker) {
         this(context,
                 new RecentTasksList(
                         context,
                         MAIN_EXECUTOR,
                         context.getSystemService(KeyguardManager.class),
-                        SystemUiProxy.INSTANCE.get(context),
-                        TopTaskTracker.INSTANCE.get(context)),
-                new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider),
+                        systemUiProxy,
+                        topTaskTracker),
+                new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider, displayController),
                 new TaskThumbnailCache(context, RECENTS_MODEL_EXECUTOR),
                 iconProvider,
                 TaskStackChangeListeners.getInstance(),
-                LockedUserState.get(context),
-                () -> ThemeManager.INSTANCE.get(context));
+                lockedUserState,
+                themeManagerLazy,
+                tracker);
     }
 
     @VisibleForTesting
-    RecentsModel(Context context, RecentTasksList taskList, TaskIconCache iconCache,
-            TaskThumbnailCache thumbnailCache, IconProvider iconProvider,
+    RecentsModel(@ApplicationContext Context context,
+            RecentTasksList taskList,
+            TaskIconCache iconCache,
+            TaskThumbnailCache thumbnailCache,
+            IconProvider iconProvider,
             TaskStackChangeListeners taskStackChangeListeners,
             LockedUserState lockedUserState,
-            Provider<ThemeManager> themeManagerProvider) {
+            Lazy<ThemeManager> themeManagerLazy,
+            DaggerSingletonTracker tracker) {
         mContext = context;
         mTaskList = taskList;
         mIconCache = iconCache;
         mIconCache.registerTaskVisualsChangeListener(this);
         mThumbnailCache = thumbnailCache;
         if (isCachePreloadingEnabled()) {
-            mCallbacks = new ComponentCallbacks() {
+            ComponentCallbacks componentCallbacks = new ComponentCallbacks() {
                 @Override
                 public void onConfigurationChanged(Configuration configuration) {
                     updateCacheSizeAndPreloadIfNeeded();
@@ -141,20 +163,27 @@
                 public void onLowMemory() {
                 }
             };
-            context.registerComponentCallbacks(mCallbacks);
-        } else {
-            mCallbacks = null;
+            context.registerComponentCallbacks(componentCallbacks);
+            tracker.addCloseable(() -> context.unregisterComponentCallbacks(componentCallbacks));
         }
 
-        mTaskStackChangeListeners = taskStackChangeListeners;
-        mTaskStackChangeListeners.registerTaskStackListener(this);
-        mIconChangeCloseable = iconProvider.registerIconChangeListener(
+        taskStackChangeListeners.registerTaskStackListener(this);
+        SafeCloseable iconChangeCloseable = iconProvider.registerIconChangeListener(
                 this::onAppIconChanged, MAIN_EXECUTOR.getHandler());
 
-        mLockedUserState = lockedUserState;
-        mThemeManagerProvider = themeManagerProvider;
-        mUnlockCallback = () -> mThemeManagerProvider.get().addChangeListener(this);
-        lockedUserState.runOnUserUnlocked(mUnlockCallback);
+        Runnable unlockCallback = () -> themeManagerLazy.get().addChangeListener(this);
+        lockedUserState.runOnUserUnlocked(unlockCallback);
+
+        tracker.addCloseable(() -> {
+            taskStackChangeListeners.unregisterTaskStackListener(this);
+            iconChangeCloseable.close();
+            mIconCache.removeTaskVisualsChangeListener();
+            if (lockedUserState.isUserUnlocked()) {
+                themeManagerLazy.get().removeChangeListener(this);
+            } else {
+                lockedUserState.removeOnUserUnlockedRunnable(unlockCallback);
+            }
+        });
     }
 
     public TaskIconCache getIconCache() {
@@ -407,22 +436,6 @@
         }
     }
 
-    @Override
-    public void close() {
-        if (mCallbacks != null) {
-            mContext.unregisterComponentCallbacks(mCallbacks);
-        }
-        mIconCache.removeTaskVisualsChangeListener();
-        mTaskStackChangeListeners.unregisterTaskStackListener(this);
-        mIconChangeCloseable.close();
-
-        if (mLockedUserState.isUserUnlocked()) {
-            mThemeManagerProvider.get().removeChangeListener(this);
-        } else {
-            mLockedUserState.removeOnUserUnlockedRunnable(mUnlockCallback);
-        }
-    }
-
     private boolean isCachePreloadingEnabled() {
         return enableGridOnlyOverview() || enableRefactorTaskThumbnail();
     }
diff --git a/quickstep/src/com/android/quickstep/TaskIconCache.kt b/quickstep/src/com/android/quickstep/TaskIconCache.kt
index bf94d41..7692e78 100644
--- a/quickstep/src/com/android/quickstep/TaskIconCache.kt
+++ b/quickstep/src/com/android/quickstep/TaskIconCache.kt
@@ -52,6 +52,7 @@
     private val context: Context,
     private val bgExecutor: Executor,
     private val iconProvider: IconProvider,
+    displayController: DisplayController,
 ) : TaskIconDataSource, DisplayInfoChangeListener {
     private val iconCache =
         TaskKeyLruCache<TaskCacheEntry>(
@@ -70,7 +71,7 @@
     var taskVisualsChangeListener: TaskVisualsChangeListener? = null
 
     init {
-        DisplayController.INSTANCE.get(context).addChangeListener(this)
+        displayController.addChangeListener(this)
     }
 
     override fun onDisplayInfoChanged(context: Context, info: DisplayController.Info, flags: Int) {
diff --git a/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java b/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
index adc45ae..853511b 100644
--- a/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
+++ b/quickstep/src/com/android/quickstep/dagger/QuickstepBaseAppComponent.java
@@ -22,6 +22,7 @@
 import com.android.launcher3.statehandlers.DesktopVisibilityController;
 import com.android.quickstep.OverviewComponentObserver;
 import com.android.quickstep.RecentsAnimationDeviceState;
+import com.android.quickstep.RecentsModel;
 import com.android.quickstep.RotationTouchHelper;
 import com.android.quickstep.SimpleOrientationTouchTransformer;
 import com.android.quickstep.SystemUiProxy;
@@ -61,6 +62,8 @@
 
     RecentsAnimationDeviceState getRecentsAnimationDeviceState();
 
+    RecentsModel getRecentsModel();
+
     SettingsChangeLogger getSettingsChangeLogger();
 
     SimpleOrientationTouchTransformer getSimpleOrientationTouchTransformer();
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
index 785e585..50d6aff 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarAutohideSuspendControllerTest.kt
@@ -22,6 +22,7 @@
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_DRAGGING
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_IN_LAUNCHER
 import com.android.launcher3.taskbar.TaskbarAutohideSuspendController.FLAG_AUTOHIDE_SUSPEND_TOUCHING
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.TaskbarMode
@@ -46,15 +47,15 @@
 
     @get:Rule(order = 0)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
+        TaskbarWindowSandboxContext.create(
+            SandboxParams({
                 spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
                     doAnswer { latestSuspendNotification = it.getArgument(0) }
                         .whenever(proxy)
                         .notifyTaskbarAutohideSuspend(anyOrNull())
                 }
-            )
-        }
+            })
+        )
     @get:Rule(order = 1) val animatorTestRule = AnimatorTestRule(this)
     @get:Rule(order = 2) val taskbarModeRule = TaskbarModeRule(context)
     @get:Rule(order = 3) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
index 9ca8a1b..bfd53ef 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarOverflowTest.kt
@@ -26,21 +26,29 @@
 import com.android.launcher3.Flags.FLAG_ENABLE_MULTI_INSTANCE_MENU_TASKBAR
 import com.android.launcher3.Flags.FLAG_TASKBAR_OVERFLOW
 import com.android.launcher3.R
+import com.android.launcher3.dagger.LauncherAppSingleton
 import com.android.launcher3.taskbar.TaskbarControllerTestUtil.runOnMainSync
 import com.android.launcher3.taskbar.TaskbarViewTestUtil.createHotseatItems
 import com.android.launcher3.taskbar.bubbles.BubbleBarViewController
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.rules.DisplayControllerModule
+import com.android.launcher3.taskbar.rules.MockedRecentsModelHelper
 import com.android.launcher3.taskbar.rules.MockedRecentsModelTestRule
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.PINNED
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.TaskbarMode
+import com.android.launcher3.taskbar.rules.TaskbarSandboxComponent
 import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule
 import com.android.launcher3.taskbar.rules.TaskbarUnitTestRule.InjectController
 import com.android.launcher3.taskbar.rules.TaskbarWindowSandboxContext
+import com.android.launcher3.util.AllModulesForTest
+import com.android.launcher3.util.FakePrefsModule
 import com.android.launcher3.util.LauncherMultivalentJUnit
 import com.android.launcher3.util.LauncherMultivalentJUnit.EmulatedDevices
 import com.android.launcher3.util.TestUtil.getOnUiThread
+import com.android.quickstep.RecentsModel
 import com.android.quickstep.SystemUiProxy
 import com.android.quickstep.util.DesktopTask
 import com.android.systemui.shared.recents.model.Task
@@ -49,6 +57,8 @@
 import com.android.wm.shell.Flags.FLAG_ENABLE_BUBBLE_BAR
 import com.android.wm.shell.desktopmode.IDesktopTaskListener
 import com.google.common.truth.Truth.assertThat
+import dagger.BindsInstance
+import dagger.Component
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
@@ -70,19 +80,25 @@
 class TaskbarOverflowTest {
     @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
 
+    val mockRecentsModelHelper: MockedRecentsModelHelper = MockedRecentsModelHelper()
+
     @get:Rule(order = 1)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
-                spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
-                    doAnswer { desktopTaskListener = it.getArgument(0) }
-                        .whenever(proxy)
-                        .setDesktopTaskListener(anyOrNull())
-                }
+        TaskbarWindowSandboxContext.create(
+            SandboxParams(
+                {
+                    spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) { proxy ->
+                        doAnswer { desktopTaskListener = it.getArgument(0) }
+                            .whenever(proxy)
+                            .setDesktopTaskListener(anyOrNull())
+                    }
+                },
+                DaggerTaskbarOverflowComponent.builder()
+                    .bindRecentsModel(mockRecentsModelHelper.mockRecentsModel),
             )
-        }
+        )
 
-    @get:Rule(order = 2) val recentsModel = MockedRecentsModelTestRule(context)
+    @get:Rule(order = 2) val recentsModel = MockedRecentsModelTestRule(mockRecentsModelHelper)
 
     @get:Rule(order = 3) val taskbarModeRule = TaskbarModeRule(context)
 
@@ -404,3 +420,18 @@
         return maxNumIconViews
     }
 }
+
+/** TaskbarOverflowComponent used to bind the RecentsModel. */
+@LauncherAppSingleton
+@Component(
+    modules = [AllModulesForTest::class, FakePrefsModule::class, DisplayControllerModule::class]
+)
+interface TaskbarOverflowComponent : TaskbarSandboxComponent {
+
+    @Component.Builder
+    interface Builder : TaskbarSandboxComponent.Builder {
+        @BindsInstance fun bindRecentsModel(model: RecentsModel): Builder
+
+        override fun build(): TaskbarOverflowComponent
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
index 360f019..ba53dcd 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/TaskbarScrimViewControllerTest.kt
@@ -25,6 +25,7 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.taskbar.rules.SandboxParams
 import com.android.launcher3.taskbar.rules.TaskbarModeRule
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.PINNED
 import com.android.launcher3.taskbar.rules.TaskbarModeRule.Mode.TRANSIENT
@@ -55,13 +56,14 @@
     @get:Rule(order = 0) val setFlagsRule = SetFlagsRule()
     @get:Rule(order = 1)
     val context =
-        TaskbarWindowSandboxContext.create { builder ->
-            builder.bindSystemUiProxy(
+        TaskbarWindowSandboxContext.create(
+            SandboxParams({
                 spy(SystemUiProxy(ApplicationProvider.getApplicationContext())) {
                     doAnswer { backPressed = true }.whenever(it).onBackEvent(anyOrNull())
                 }
-            )
-        }
+            })
+        )
+
     @get:Rule(order = 2) val taskbarModeRule = TaskbarModeRule(context)
     @get:Rule(order = 3) val animatorTestRule = AnimatorTestRule(this)
     @get:Rule(order = 4) val taskbarUnitTestRule = TaskbarUnitTestRule(this, context)
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt
new file mode 100644
index 0000000..a7bfa9a
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelHelper.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.rules
+
+import com.android.quickstep.RecentsModel
+import com.android.quickstep.RecentsModel.RecentTasksChangedListener
+import com.android.quickstep.TaskIconCache
+import com.android.quickstep.util.GroupTask
+import java.util.function.Consumer
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+
+/** Helper class to mock the {@link RecentsModel} object in test */
+class MockedRecentsModelHelper {
+    private val mockIconCache: TaskIconCache = mock()
+    var taskListId = 0
+    var recentTasksChangedListener: RecentTasksChangedListener? = null
+    var taskRequests: MutableList<(List<GroupTask>) -> Unit> = mutableListOf()
+
+    val mockRecentsModel: RecentsModel = mock {
+        on { iconCache } doReturn mockIconCache
+
+        on { unregisterRecentTasksChangedListener() } doAnswer { recentTasksChangedListener = null }
+
+        on { registerRecentTasksChangedListener(any<RecentTasksChangedListener>()) } doAnswer
+            {
+                recentTasksChangedListener = it.getArgument<RecentTasksChangedListener>(0)
+            }
+
+        on { getTasks(anyOrNull(), anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { getTasks(anyOrNull()) } doAnswer
+            {
+                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
+                if (request != null) {
+                    taskRequests.add { response -> request.accept(response) }
+                }
+                taskListId
+            }
+
+        on { isTaskListValid(any()) } doAnswer { taskListId == it.getArgument(0) }
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
index ed1443d..359b876 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/MockedRecentsModelTestRule.kt
@@ -16,64 +16,17 @@
 
 package com.android.launcher3.taskbar.rules
 
-import com.android.quickstep.RecentsModel
-import com.android.quickstep.RecentsModel.RecentTasksChangedListener
-import com.android.quickstep.TaskIconCache
 import com.android.quickstep.util.GroupTask
-import java.util.function.Consumer
 import org.junit.rules.TestRule
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
-import org.mockito.kotlin.any
-import org.mockito.kotlin.anyOrNull
-import org.mockito.kotlin.doAnswer
-import org.mockito.kotlin.doReturn
-import org.mockito.kotlin.mock
 
-class MockedRecentsModelTestRule(private val context: TaskbarWindowSandboxContext) : TestRule {
-
-    private val mockIconCache: TaskIconCache = mock()
-
-    private val mockRecentsModel: RecentsModel = mock {
-        on { iconCache } doReturn mockIconCache
-
-        on { unregisterRecentTasksChangedListener() } doAnswer { recentTasksChangedListener = null }
-
-        on { registerRecentTasksChangedListener(any<RecentTasksChangedListener>()) } doAnswer
-            {
-                recentTasksChangedListener = it.getArgument<RecentTasksChangedListener>(0)
-            }
-
-        on { getTasks(anyOrNull(), anyOrNull()) } doAnswer
-            {
-                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
-                if (request != null) {
-                    taskRequests.add { response -> request.accept(response) }
-                }
-                taskListId
-            }
-
-        on { getTasks(anyOrNull()) } doAnswer
-            {
-                val request = it.getArgument<Consumer<List<GroupTask>>?>(0)
-                if (request != null) {
-                    taskRequests.add { response -> request.accept(response) }
-                }
-                taskListId
-            }
-
-        on { isTaskListValid(any()) } doAnswer { taskListId == it.getArgument(0) }
-    }
-
+class MockedRecentsModelTestRule(private val modelHelper: MockedRecentsModelHelper) : TestRule {
     private var recentTasks: List<GroupTask> = emptyList()
-    private var taskListId = 0
-    private var recentTasksChangedListener: RecentTasksChangedListener? = null
-    private var taskRequests: MutableList<(List<GroupTask>) -> Unit> = mutableListOf()
 
     override fun apply(base: Statement?, description: Description?): Statement {
         return object : Statement() {
             override fun evaluate() {
-                context.putObject(RecentsModel.INSTANCE, mockRecentsModel)
                 base?.evaluate()
             }
         }
@@ -82,15 +35,15 @@
     // NOTE: For the update to take effect, `resolvePendingTaskRequests()` needs to be called, so
     // calbacks to any pending `RecentsModel.getTasks()` get called with the updated task list.
     fun updateRecentTasks(tasks: List<GroupTask>) {
-        ++taskListId
+        ++modelHelper.taskListId
         recentTasks = tasks
-        recentTasksChangedListener?.onRecentTasksChanged()
+        modelHelper.recentTasksChangedListener?.onRecentTasksChanged()
     }
 
     fun resolvePendingTaskRequests() {
         val requests = mutableListOf<(List<GroupTask>) -> Unit>()
-        requests.addAll(taskRequests)
-        taskRequests.clear()
+        requests.addAll(modelHelper.taskRequests)
+        modelHelper.taskRequests.clear()
 
         requests.forEach { it(recentTasks) }
     }
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
index e6dc2a2..95e8980 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/rules/TaskbarWindowSandboxContext.kt
@@ -47,10 +47,6 @@
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
 
-/** Include additional bindings when building a [TaskbarSandboxComponent]. */
-typealias TaskbarComponentBinder =
-    TaskbarWindowSandboxContext.(TaskbarSandboxComponent.Builder) -> Unit
-
 /**
  * [SandboxApplication] for running Taskbar tests.
  *
@@ -61,7 +57,7 @@
 private constructor(
     private val base: SandboxApplication,
     val virtualDisplay: VirtualDisplay,
-    private val componentBinder: TaskbarComponentBinder?,
+    private val params: SandboxParams,
 ) : ContextWrapper(base), ObjectSandbox by base, TestRule {
 
     val settingsCacheSandbox = SettingsCacheSandbox()
@@ -76,10 +72,9 @@
             override fun before() {
                 val context = this@TaskbarWindowSandboxContext
                 val builder =
-                    DaggerTaskbarSandboxComponent.builder()
-                        .bindSystemUiProxy(SystemUiProxy(context))
+                    params.builderBase
+                        .bindSystemUiProxy(params.systemUiProxyProvider.invoke(context))
                         .bindSettingsCache(settingsCacheSandbox.cache)
-                componentBinder?.invoke(context, builder)
                 base.initDaggerComponent(builder)
             }
         }
@@ -95,10 +90,9 @@
         private const val VIRTUAL_DISPLAY_NAME = "TaskbarSandboxDisplay"
 
         /** Creates a [SandboxApplication] for Taskbar tests. */
-        fun create(componentBinder: TaskbarComponentBinder? = null): TaskbarWindowSandboxContext {
+        fun create(params: SandboxParams = SandboxParams()): TaskbarWindowSandboxContext {
             val base = ApplicationProvider.getApplicationContext<Context>()
             val displayManager = checkNotNull(base.getSystemService(DisplayManager::class.java))
-
             // Create virtual display to avoid clashing with Taskbar on default display.
             val virtualDisplay =
                 base.resources.displayMetrics.let {
@@ -115,7 +109,7 @@
             return TaskbarWindowSandboxContext(
                 SandboxApplication(base.createDisplayContext(virtualDisplay.display)),
                 virtualDisplay,
-                componentBinder,
+                params,
             )
         }
     }
@@ -157,3 +151,9 @@
         override fun build(): TaskbarSandboxComponent
     }
 }
+
+/** Include additional bindings when building a [TaskbarSandboxComponent]. */
+data class SandboxParams(
+    val systemUiProxyProvider: (Context) -> SystemUiProxy = { SystemUiProxy(it) },
+    val builderBase: TaskbarSandboxComponent.Builder = DaggerTaskbarSandboxComponent.builder(),
+)
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java b/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
index 722e1da..2eb2e4c 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/RecentsModelTest.java
@@ -43,6 +43,7 @@
 import com.android.launcher3.R;
 import com.android.launcher3.graphics.ThemeManager;
 import com.android.launcher3.icons.IconProvider;
+import com.android.launcher3.util.DaggerSingletonTracker;
 import com.android.launcher3.util.LockedUserState;
 import com.android.launcher3.util.SplitConfigurationOptions;
 import com.android.quickstep.util.GroupTask;
@@ -109,7 +110,7 @@
 
         mRecentsModel = new RecentsModel(mContext, mTasksList, mock(TaskIconCache.class),
                 mThumbnailCache, mock(IconProvider.class), mock(TaskStackChangeListeners.class),
-                mLockedUserState, () -> mThemeManager);
+                mLockedUserState, () -> mThemeManager, mock(DaggerSingletonTracker.class));
 
         mResource = mock(Resources.class);
         when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(3);