diff --git a/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt b/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt new file mode 100644 index 0000000000..3823100562 --- /dev/null +++ b/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt @@ -0,0 +1,49 @@ +/* + * 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.quickstep.recents.domain.model + +import android.graphics.drawable.Drawable +import com.android.systemui.shared.recents.model.ThumbnailData + +/** + * Data class representing a task in the application. + * + * This class holds the essential information about a task, including its unique identifier, display + * title, associated icon, optional thumbnail data, and background color. + * + * @property id The unique identifier for this task. Must be an integer. + * @property title The display title of the task. + * @property titleDescription A content description of the task. + * @property icon An optional drawable resource representing an icon for the task. Can be null if no + * icon is required. + * @property thumbnail An optional [ThumbnailData] object containing thumbnail information. Can be + * null if no thumbnail is needed. + * @property backgroundColor The background color of the task, represented as an integer color + * value. + * @property isLocked Indicates whether the [Task] is locked. + */ +data class TaskModel( + val id: TaskId, + val title: String, + val titleDescription: String?, + val icon: Drawable?, + val thumbnail: ThumbnailData?, + val backgroundColor: Int, + val isLocked: Boolean, +) + +typealias TaskId = Int diff --git a/quickstep/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCase.kt b/quickstep/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCase.kt new file mode 100644 index 0000000000..a60144ba38 --- /dev/null +++ b/quickstep/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCase.kt @@ -0,0 +1,41 @@ +/* + * 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.quickstep.recents.domain.usecase + +import com.android.quickstep.recents.data.RecentTasksRepository +import com.android.quickstep.recents.domain.model.TaskModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetTaskUseCase(private val repository: RecentTasksRepository) { + operator fun invoke(taskId: Int): Flow = + repository.getTaskDataById(taskId).map { task -> + if (task != null) { + TaskModel( + id = task.key.id, + title = task.title, + titleDescription = task.titleDescription, + icon = task.icon, + thumbnail = task.thumbnail, + backgroundColor = task.colorBackground, + isLocked = task.isLocked, + ) + } else { + null + } + } +} diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt new file mode 100644 index 0000000000..5f98479349 --- /dev/null +++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt @@ -0,0 +1,57 @@ +/* + * 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.quickstep.recents.ui.viewmodel + +import android.graphics.drawable.Drawable +import com.android.systemui.shared.recents.model.ThumbnailData + +/** + * This class represents the UI state to be consumed by TaskView, GroupTaskView and DesktopTaskView. + * Data class representing the state of a list of tasks. + * + * This class encapsulates a list of [TaskTileUiState] objects, along with a flag indicating whether + * the data is being used for a live tile display. + * + * @property tasks The list of [TaskTileUiState] objects representing the individual tasks. + * @property isLiveTile Indicates whether this data is intended for a live tile. If `true`, the + * running app will be displayed instead of the thumbnail. + */ +data class TaskTileUiState(val tasks: List, val isLiveTile: Boolean) + +sealed interface TaskData { + /** When no data was found for the TaskId provided */ + data class NoData(val taskId: Int) : TaskData + + /** + * This class provides UI information related to a Task (App) to be displayed within a TaskView. + * + * @property taskId Identifier of the task + * @property title App title + * @property icon App icon + * @property thumbnailData Information related to the last snapshot retrieved from the app + * @property backgroundColor The background color of the task. + * @property isLocked Indicates whether the task is locked or not. + */ + data class Data( + val taskId: Int, + val title: String, + val icon: Drawable?, + val thumbnailData: ThumbnailData?, + val backgroundColor: Int, + val isLocked: Boolean, + ) : TaskData +} diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt new file mode 100644 index 0000000000..2e51a8a8cb --- /dev/null +++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt @@ -0,0 +1,91 @@ +/* + * 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.quickstep.recents.ui.viewmodel + +import android.util.Log +import com.android.launcher3.util.coroutines.DispatcherProvider +import com.android.quickstep.recents.domain.model.TaskId +import com.android.quickstep.recents.domain.model.TaskModel +import com.android.quickstep.recents.domain.usecase.GetTaskUseCase +import com.android.quickstep.recents.viewmodel.RecentsViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +/** + * ViewModel used for [com.android.quickstep.views.TaskView], + * [com.android.quickstep.views.DesktopTaskView] and [com.android.quickstep.views.GroupedTaskView]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TaskViewModel( + recentsViewData: RecentsViewData, + private val getTaskUseCase: GetTaskUseCase, + dispatcherProvider: DispatcherProvider, +) { + private var taskIds = MutableStateFlow(emptySet()) + + private val isLiveTile = + combine( + taskIds, + recentsViewData.runningTaskIds, + recentsViewData.runningTaskShowScreenshot, + ) { taskIds, runningTaskIds, runningTaskShowScreenshot -> + runningTaskIds == taskIds && !runningTaskShowScreenshot + } + .distinctUntilChanged() + + val state: Flow = + taskIds + .flatMapLatest { ids -> + // Combine Tasks requests + combine( + ids.map { id -> getTaskUseCase(id).map { taskModel -> id to taskModel } }, + ::mapToUiState, + ) + } + .combine(isLiveTile) { tasks, isLiveTile -> TaskTileUiState(tasks, isLiveTile) } + .flowOn(dispatcherProvider.background) + + fun bind(vararg taskId: TaskId) { + Log.d(TAG, "bind: $taskId") + taskIds.value = taskId.toSet() + } + + private fun mapToUiState(result: Array>): List = + result.map { mapToUiState(it.first, it.second) } + + private fun mapToUiState(taskId: TaskId, result: TaskModel?): TaskData = + result?.let { + TaskData.Data( + taskId = taskId, + title = result.title, + icon = result.icon, + thumbnailData = result.thumbnail, + backgroundColor = result.backgroundColor, + isLocked = result.isLocked, + ) + } ?: TaskData.NoData(taskId) + + private companion object { + const val TAG = "TaskViewModel" + } +} diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt index dc849f31a1..99df84c72f 100644 --- a/quickstep/src/com/android/quickstep/views/TaskView.kt +++ b/quickstep/src/com/android/quickstep/views/TaskView.kt @@ -613,6 +613,7 @@ constructor( borderEnabled = false hoverBorderVisible = false taskViewId = UNBOUND_TASK_VIEW_ID + // TODO(b/390583187): Clean the components UI State when TaskView is recycled. taskContainers.forEach { it.destroy() } } diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTasksRepository.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTasksRepository.kt index 1c9ce0bbec..35af29f2c6 100644 --- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTasksRepository.kt +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/data/FakeTasksRepository.kt @@ -58,10 +58,10 @@ class FakeTasksRepository : RecentTasksRepository { tasks.value.map { it.apply { thumbnail = thumbnailDataMap[it.key.id] - taskIconDataMap[it.key.id].let { data -> - title = data?.title - titleDescription = data?.titleDescription - icon = data?.icon + taskIconDataMap[it.key.id]?.let { data -> + title = data.title + titleDescription = data.titleDescription + icon = data.icon } } } diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCaseTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCaseTest.kt new file mode 100644 index 0000000000..b036bceea7 --- /dev/null +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/domain/usecase/GetTaskUseCaseTest.kt @@ -0,0 +1,107 @@ +/* + * 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.quickstep.recents.domain.usecase + +import android.content.ComponentName +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.quickstep.recents.data.FakeTasksRepository +import com.android.quickstep.recents.domain.model.TaskModel +import com.android.systemui.shared.recents.model.Task +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class GetTaskUseCaseTest { + private val unconfinedTestDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(unconfinedTestDispatcher) + + private val tasksRepository = FakeTasksRepository() + private val sut = GetTaskUseCase(repository = tasksRepository) + + @Before + fun setUp() { + tasksRepository.seedTasks(listOf(TASK_1)) + } + + @Test + fun taskNotSeeded_returnsNull() = + testScope.runTest { + val result = sut.invoke(NOT_FOUND_TASK_ID).firstOrNull() + assertThat(result).isNull() + } + + @Test + fun taskNotVisible_returnsNull() = + testScope.runTest { + val result = sut.invoke(TASK_1_ID).firstOrNull() + assertThat(result).isNull() + } + + @Test + fun taskVisible_returnsData() = + testScope.runTest { + tasksRepository.setVisibleTasks(setOf(TASK_1_ID)) + val expectedResult = + TaskModel( + id = TASK_1_ID, + title = "Title $TASK_1_ID", + titleDescription = "Content Description $TASK_1_ID", + icon = TASK_1_ICON, + thumbnail = null, + backgroundColor = Color.BLACK, + isLocked = false, + ) + val result = sut.invoke(TASK_1_ID).firstOrNull() + assertThat(result).isEqualTo(expectedResult) + } + + private companion object { + const val NOT_FOUND_TASK_ID = 404 + private const val TASK_1_ID = 1 + private val TASK_1_ICON = ShapeDrawable() + private val TASK_1 = + Task( + Task.TaskKey( + /* id = */ TASK_1_ID, + /* windowingMode = */ 0, + /* intent = */ Intent(), + /* sourceComponent = */ ComponentName("", ""), + /* userId = */ 0, + /* lastActiveTime = */ 2000, + ) + ) + .apply { + title = "Title 1" + titleDescription = "Content Description 1" + colorBackground = Color.BLACK + icon = TASK_1_ICON + thumbnail = null + isLocked = false + } + } +} diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt new file mode 100644 index 0000000000..54a27e9062 --- /dev/null +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModelTest.kt @@ -0,0 +1,216 @@ +/* + * 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.quickstep.recents.ui.viewmodel + +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.launcher3.util.TestDispatcherProvider +import com.android.quickstep.recents.domain.model.TaskModel +import com.android.quickstep.recents.domain.usecase.GetTaskUseCase +import com.android.quickstep.recents.viewmodel.RecentsViewData +import com.android.systemui.shared.recents.model.ThumbnailData +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class TaskViewModelTest { + private val unconfinedTestDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(unconfinedTestDispatcher) + + private val recentsViewData = RecentsViewData() + private val getTaskUseCase = mock() + private val sut = + TaskViewModel( + recentsViewData = recentsViewData, + getTaskUseCase = getTaskUseCase, + dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher), + ) + + @Before + fun setUp() { + whenever(getTaskUseCase.invoke(TASK_MODEL_1.id)).thenReturn(flow { emit(TASK_MODEL_1) }) + whenever(getTaskUseCase.invoke(TASK_MODEL_2.id)).thenReturn(flow { emit(TASK_MODEL_2) }) + whenever(getTaskUseCase.invoke(TASK_MODEL_3.id)).thenReturn(flow { emit(TASK_MODEL_3) }) + whenever(getTaskUseCase.invoke(INVALID_TASK_ID)).thenReturn(flow { emit(null) }) + recentsViewData.runningTaskIds.value = emptySet() + } + + @Test + fun singleTaskRetrieved_when_validTaskId() = + testScope.runTest { + sut.bind(TASK_MODEL_1.id) + val expectedResult = TaskTileUiState(listOf(TASK_MODEL_1.toUiState()), false) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun multipleTasksRetrieved_when_validTaskIds() = + testScope.runTest { + sut.bind(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id, INVALID_TASK_ID) + val expectedResult = + TaskTileUiState( + tasks = + listOf( + TASK_MODEL_1.toUiState(), + TASK_MODEL_2.toUiState(), + TASK_MODEL_3.toUiState(), + TaskData.NoData(INVALID_TASK_ID), + ), + isLiveTile = false, + ) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun isLiveTile_when_runningTasksMatchTasks() = + testScope.runTest { + recentsViewData.runningTaskShowScreenshot.value = false + recentsViewData.runningTaskIds.value = + setOf(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + sut.bind(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + val expectedResult = + TaskTileUiState( + tasks = + listOf( + TASK_MODEL_1.toUiState(), + TASK_MODEL_2.toUiState(), + TASK_MODEL_3.toUiState(), + ), + isLiveTile = true, + ) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun isNotLiveTile_when_runningTaskShowScreenshotIsTrue() = + testScope.runTest { + recentsViewData.runningTaskShowScreenshot.value = true + recentsViewData.runningTaskIds.value = + setOf(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + sut.bind(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + val expectedResult = + TaskTileUiState( + tasks = + listOf( + TASK_MODEL_1.toUiState(), + TASK_MODEL_2.toUiState(), + TASK_MODEL_3.toUiState(), + ), + isLiveTile = false, + ) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun isNotLiveTile_when_runningTasksMatchPartialTasks_lessRunningTasks() = + testScope.runTest { + recentsViewData.runningTaskShowScreenshot.value = false + recentsViewData.runningTaskIds.value = setOf(TASK_MODEL_1.id, TASK_MODEL_2.id) + sut.bind(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + val expectedResult = + TaskTileUiState( + tasks = + listOf( + TASK_MODEL_1.toUiState(), + TASK_MODEL_2.toUiState(), + TASK_MODEL_3.toUiState(), + ), + isLiveTile = false, + ) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun isNotLiveTile_when_runningTasksMatchPartialTasks_moreRunningTasks() = + testScope.runTest { + recentsViewData.runningTaskShowScreenshot.value = false + recentsViewData.runningTaskIds.value = + setOf(TASK_MODEL_1.id, TASK_MODEL_2.id, TASK_MODEL_3.id) + sut.bind(TASK_MODEL_1.id, TASK_MODEL_2.id) + val expectedResult = + TaskTileUiState( + tasks = listOf(TASK_MODEL_1.toUiState(), TASK_MODEL_2.toUiState()), + isLiveTile = false, + ) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + @Test + fun noDataAvailable_when_InvalidTaskId() = + testScope.runTest { + sut.bind(INVALID_TASK_ID) + val expectedResult = + TaskTileUiState(listOf(TaskData.NoData(INVALID_TASK_ID)), isLiveTile = false) + assertThat(sut.state.first()).isEqualTo(expectedResult) + } + + private fun TaskModel.toUiState() = + TaskData.Data( + taskId = id, + title = title, + icon = icon!!, + thumbnailData = thumbnail, + backgroundColor = backgroundColor, + isLocked = isLocked, + ) + + companion object { + const val INVALID_TASK_ID = -1 + val TASK_MODEL_1 = + TaskModel( + 1, + "Title 1", + "Content Description 1", + ShapeDrawable(), + ThumbnailData(), + Color.BLACK, + false, + ) + val TASK_MODEL_2 = + TaskModel( + 2, + "Title 2", + "Content Description 2", + ShapeDrawable(), + ThumbnailData(), + Color.RED, + true, + ) + val TASK_MODEL_3 = + TaskModel( + 3, + "Title 3", + "Content Description 3", + ShapeDrawable(), + ThumbnailData(), + Color.BLUE, + false, + ) + } +} diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/usecase/GetThumbnailUseCaseTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/usecase/GetThumbnailUseCaseTest.kt index 73aa46035c..004463159a 100644 --- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/usecase/GetThumbnailUseCaseTest.kt +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/usecase/GetThumbnailUseCaseTest.kt @@ -76,7 +76,7 @@ class GetThumbnailUseCaseTest { assertThat(systemUnderTest.run(TASK_ID)).isEqualTo(thumbnailData.thumbnail) } - companion object { + private companion object { const val TASK_ID = 0 const val THUMBNAIL_WIDTH = 100 const val THUMBNAIL_HEIGHT = 200