Merge "Add icon loading to TasksRepository" into main
diff --git a/quickstep/src/com/android/quickstep/TaskIconCache.java b/quickstep/src/com/android/quickstep/TaskIconCache.java
index b3a9199..1f6c02c 100644
--- a/quickstep/src/com/android/quickstep/TaskIconCache.java
+++ b/quickstep/src/com/android/quickstep/TaskIconCache.java
@@ -33,6 +33,7 @@
 import android.text.TextUtils;
 import android.util.SparseArray;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.WorkerThread;
 
 import com.android.launcher3.R;
@@ -48,6 +49,7 @@
 import com.android.launcher3.util.DisplayController.Info;
 import com.android.launcher3.util.FlagOp;
 import com.android.launcher3.util.Preconditions;
+import com.android.quickstep.task.thumbnail.data.TaskIconDataSource;
 import com.android.quickstep.util.TaskKeyLruCache;
 import com.android.quickstep.util.TaskVisualsChangeListener;
 import com.android.systemui.shared.recents.model.Task;
@@ -59,7 +61,7 @@
 /**
  * Manages the caching of task icons and related data.
  */
-public class TaskIconCache implements DisplayInfoChangeListener {
+public class TaskIconCache implements TaskIconDataSource, DisplayInfoChangeListener {
 
     private final Executor mBgExecutor;
 
@@ -102,7 +104,8 @@
      * @param callback The callback to receive the task after its data has been populated.
      * @return A cancelable handle to the request
      */
-    public CancellableTask getIconInBackground(Task task, GetTaskIconCallback callback) {
+    @Override
+    public CancellableTask getIconInBackground(Task task, @NonNull GetTaskIconCallback callback) {
         Preconditions.assertUIThread();
         if (task.icon != null) {
             // Nothing to load, the icon is already loaded
diff --git a/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt b/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
index 4d6dfc3..f73db5a 100644
--- a/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
+++ b/quickstep/src/com/android/quickstep/recents/data/TasksRepository.kt
@@ -16,7 +16,8 @@
 
 package com.android.quickstep.recents.data
 
-import com.android.quickstep.TaskIconCache
+import android.graphics.drawable.Drawable
+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
@@ -38,7 +39,7 @@
 class TasksRepository(
     private val recentsModel: RecentTasksDataSource,
     private val taskThumbnailDataSource: TaskThumbnailDataSource,
-    private val taskIconCache: TaskIconCache,
+    private val taskIconDataSource: TaskIconDataSource,
 ) : RecentTasksRepository {
     private val groupedTaskData = MutableStateFlow(emptyList<GroupTask>())
     private val _taskData =
@@ -46,10 +47,19 @@
     private val visibleTaskIds = MutableStateFlow(emptySet<Int>())
 
     private val taskData: Flow<List<Task>> =
-        combine(_taskData, getThumbnailQueryResults()) { tasks, results ->
+        combine(_taskData, getThumbnailQueryResults(), getIconQueryResults()) {
+            tasks,
+            thumbnailQueryResults,
+            iconQueryResults ->
             tasks.forEach { task ->
                 // Add retrieved thumbnails + remove unnecessary thumbnails
-                task.thumbnail = results[task.key.id]
+                task.thumbnail = 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
         }
@@ -79,7 +89,6 @@
                     suspendCancellableCoroutine { continuation ->
                         val cancellableTask =
                             taskThumbnailDataSource.getThumbnailInBackground(task) {
-                                task.thumbnail = it
                                 continuation.resume(it)
                             }
                         continuation.invokeOnCancellation { cancellableTask?.cancel() }
@@ -109,6 +118,59 @@
             }
         }
     }
+
+    /** Flow wrapper for [TaskThumbnailDataSource.getThumbnailInBackground] api */
+    private fun getIconDataRequest(task: Task): IconDataRequest =
+        flow {
+                emit(task.key.id to task.getTaskIconQueryResponse())
+                val iconDataResponse: TaskIconQueryResponse? =
+                    suspendCancellableCoroutine { continuation ->
+                        val cancellableTask =
+                            taskIconDataSource.getIconInBackground(task) {
+                                icon,
+                                contentDescription,
+                                title ->
+                                continuation.resume(
+                                    TaskIconQueryResponse(icon, contentDescription, title)
+                                )
+                            }
+                        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() }
+            }
+        }
+    }
 }
 
-typealias ThumbnailDataRequest = Flow<Pair<Int, ThumbnailData?>>
+private data class TaskIconQueryResponse(
+    val icon: Drawable,
+    val contentDescription: String,
+    val title: String
+)
+
+private fun Task.getTaskIconQueryResponse(): TaskIconQueryResponse? {
+    val iconVal = icon ?: return null
+    val titleDescriptionVal = titleDescription ?: return null
+    val titleVal = title ?: return null
+
+    return TaskIconQueryResponse(iconVal, titleDescriptionVal, titleVal)
+}
+
+private typealias ThumbnailDataRequest = Flow<Pair<Int, ThumbnailData?>>
+
+private typealias IconDataRequest = Flow<Pair<Int, TaskIconQueryResponse?>>
diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/data/TaskIconDataSource.kt b/quickstep/src/com/android/quickstep/task/thumbnail/data/TaskIconDataSource.kt
new file mode 100644
index 0000000..ab699c6
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/task/thumbnail/data/TaskIconDataSource.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.quickstep.task.thumbnail.data
+
+import com.android.launcher3.util.CancellableTask
+import com.android.quickstep.TaskIconCache.GetTaskIconCallback
+import com.android.systemui.shared.recents.model.Task
+
+interface TaskIconDataSource {
+    fun getIconInBackground(task: Task, callback: GetTaskIconCallback): CancellableTask<*>?
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTaskIconDataSource.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTaskIconDataSource.kt
new file mode 100644
index 0000000..242bc73
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTaskIconDataSource.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.quickstep.recents.data
+
+import android.graphics.drawable.Drawable
+import com.android.launcher3.util.CancellableTask
+import com.android.quickstep.TaskIconCache
+import com.android.quickstep.task.thumbnail.data.TaskIconDataSource
+import com.android.systemui.shared.recents.model.Task
+import com.google.common.truth.Truth.assertThat
+import org.mockito.kotlin.mock
+
+class FakeTaskIconDataSource : TaskIconDataSource {
+
+    val taskIdToDrawable: Map<Int, Drawable> = (0..10).associateWith { mock() }
+    val taskIdToUpdatingTask: MutableMap<Int, () -> Unit> = mutableMapOf()
+    var shouldLoadSynchronously: Boolean = true
+
+    /** Retrieves and sets an icon on [task] from [taskIdToDrawable]. */
+    override fun getIconInBackground(
+        task: Task,
+        callback: TaskIconCache.GetTaskIconCallback
+    ): CancellableTask<*>? {
+        val wrappedCallback = {
+            callback.onTaskIconReceived(
+                taskIdToDrawable.getValue(task.key.id),
+                "content desc ${task.key.id}",
+                "title ${task.key.id}"
+            )
+        }
+        if (shouldLoadSynchronously) {
+            wrappedCallback()
+        } else {
+            taskIdToUpdatingTask[task.key.id] = wrappedCallback
+        }
+        return null
+    }
+}
+
+fun Task.assertHasIconDataFromSource(fakeTaskIconDataSource: FakeTaskIconDataSource) {
+    assertThat(icon).isEqualTo(fakeTaskIconDataSource.taskIdToDrawable[key.id])
+    assertThat(titleDescription).isEqualTo("content desc ${key.id}")
+    assertThat(title).isEqualTo("title ${key.id}")
+}
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 c28a85a..88fa190 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
@@ -18,7 +18,6 @@
 
 import android.content.ComponentName
 import android.content.Intent
-import com.android.quickstep.TaskIconCache
 import com.android.quickstep.util.DesktopTask
 import com.android.quickstep.util.GroupTask
 import com.android.systemui.shared.recents.model.Task
@@ -31,7 +30,6 @@
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
-import org.mockito.kotlin.mock
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class TasksRepositoryTest {
@@ -44,10 +42,10 @@
         )
     private val recentsModel = FakeRecentTasksDataSource()
     private val taskThumbnailDataSource = FakeTaskThumbnailDataSource()
-    private val taskIconCache = mock<TaskIconCache>()
+    private val taskIconDataSource = FakeTaskIconDataSource()
 
     private val systemUnderTest =
-        TasksRepository(recentsModel, taskThumbnailDataSource, taskIconCache)
+        TasksRepository(recentsModel, taskThumbnailDataSource, taskIconDataSource)
 
     @Test
     fun getAllTaskDataReturnsFlattenedListOfTasks() = runTest {
@@ -81,6 +79,22 @@
     }
 
     @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]
@@ -101,7 +115,28 @@
     }
 
     @Test
-    fun retrievedThumbnailsAreDiscardedWhenTaskBecomesInvisible() = runTest {
+    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)
@@ -109,14 +144,20 @@
         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)
+        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))
 
-        assertThat(systemUnderTest.getTaskDataById(2).first()!!.thumbnail).isNull()
+        val task2AfterVisibleTasksChanged = systemUnderTest.getTaskDataById(2).first()!!
+        assertThat(task2AfterVisibleTasksChanged.thumbnail).isNull()
+        assertThat(task2AfterVisibleTasksChanged.icon).isNull()
+        assertThat(task2AfterVisibleTasksChanged.titleDescription).isNull()
+        assertThat(task2AfterVisibleTasksChanged.title).isNull()
     }
 
     @Test