From 3bf37408915822940af28aeae0f7e21984c5d81a Mon Sep 17 00:00:00 2001 From: Jordan Silva Date: Thu, 23 Jan 2025 03:51:50 -0800 Subject: [PATCH] Refactor: Remove TaskThumbnailViewModel and introduce TaskViewModel - Remove TaskThumbnailViewModel and replace it with TaskViewModel - Introduce TaskUiStateMapper class to map between TaskData and TaskThumbnailUiState - Improve TaskView by introducing TaskViewModel and delegate to TaskContainer to show changes - Refactor TaskThumbnailView to simplify its responsability and be able to receive TaskThumbnailUiState. - Add TaskViewModel test and TaskUiStateMapper tests Bug: 390583187 Doc: go/launcher-overview-unified-taskviewmodel Flag: com.android.launcher3.enable_refactor_task_thumbnail Test: OverviewImageTests Test: TaskViewModelTest Test: TaskUiStateMapperTest Test: TaskThumbnailViewModelImplTest Test: TaskThumbnailViewScreenshotTest Change-Id: I90bb08d377d7a24ca803c552e0cf3897c8888b76 --- .../recents/di/RecentsDependencies.kt | 4 +- .../recents/domain/model/TaskModel.kt | 2 +- .../recents/ui/mapper/TaskUiStateMapper.kt | 86 ++++++ .../recents/ui/viewmodel/TaskTileUiState.kt | 20 +- .../recents/ui/viewmodel/TaskViewModel.kt | 18 +- .../task/thumbnail/TaskThumbnailView.kt | 36 ++- .../task/viewmodel/TaskThumbnailViewModel.kt | 4 - .../viewmodel/TaskThumbnailViewModelImpl.kt | 108 ------- .../android/quickstep/views/TaskContainer.kt | 6 + .../com/android/quickstep/views/TaskView.kt | 68 +++++ .../thumbnail/FakeTaskThumbnailViewModel.kt | 2 - .../TaskThumbnailViewScreenshotTest.kt | 27 +- .../ui/mapper/TaskUiStateMapperTest.kt | 231 +++++++++++++++ .../recents/ui/viewmodel/TaskViewModelTest.kt | 57 +++- .../TaskThumbnailViewModelImplTest.kt | 266 +----------------- 15 files changed, 500 insertions(+), 435 deletions(-) create mode 100644 quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt create mode 100644 quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt diff --git a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt index 358537cb29..0a47338333 100644 --- a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt +++ b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt @@ -26,6 +26,7 @@ import com.android.quickstep.recents.data.RecentTasksRepository import com.android.quickstep.recents.data.TaskVisualsChangedDelegate import com.android.quickstep.recents.data.TaskVisualsChangedDelegateImpl import com.android.quickstep.recents.data.TasksRepository +import com.android.quickstep.recents.domain.usecase.GetTaskUseCase import com.android.quickstep.recents.usecase.GetThumbnailPositionUseCase import com.android.quickstep.recents.usecase.GetThumbnailUseCase import com.android.quickstep.recents.usecase.SysUiStatusNavFlagsUseCase @@ -181,8 +182,6 @@ class RecentsDependencies private constructor(private val appContext: Context) { taskContainerData = inject(scopeId), dispatcherProvider = inject(), getThumbnailPositionUseCase = inject(), - tasksRepository = inject(), - deviceProfileRepository = inject(), splashAlphaUseCase = inject(scopeId), ) TaskOverlayViewModel::class.java -> { @@ -195,6 +194,7 @@ class RecentsDependencies private constructor(private val appContext: Context) { dispatcherProvider = inject(), ) } + GetTaskUseCase::class.java -> GetTaskUseCase(repository = inject()) GetThumbnailUseCase::class.java -> GetThumbnailUseCase(taskRepository = inject()) SysUiStatusNavFlagsUseCase::class.java -> SysUiStatusNavFlagsUseCase(taskRepository = inject()) diff --git a/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt b/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt index 3823100562..bf29b1d692 100644 --- a/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt +++ b/quickstep/src/com/android/quickstep/recents/domain/model/TaskModel.kt @@ -38,7 +38,7 @@ import com.android.systemui.shared.recents.model.ThumbnailData */ data class TaskModel( val id: TaskId, - val title: String, + val title: String?, val titleDescription: String?, val icon: Drawable?, val thumbnail: ThumbnailData?, diff --git a/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt new file mode 100644 index 0000000000..fb622686dc --- /dev/null +++ b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt @@ -0,0 +1,86 @@ +/* + * 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.mapper + +import com.android.quickstep.recents.ui.viewmodel.TaskData +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.SnapshotSplash +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.ThumbnailHeader +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized + +object TaskUiStateMapper { + + /** + * Converts a [TaskData] object into a [TaskThumbnailUiState] for display in the UI. + * + * This function handles different types of [TaskData] and determines the appropriate UI state + * based on the data and provided flags. + * + * @param taskData The [TaskData] to convert. Can be null or a specific subclass. + * @param isLiveTile A flag indicating whether the task data represents live tile. + * @param hasHeader A flag indicating whether the UI should display a header. + * @return A [TaskThumbnailUiState] representing the UI state for the given task data. + */ + fun toTaskThumbnailUiState( + taskData: TaskData?, + isLiveTile: Boolean, + hasHeader: Boolean, + ): TaskThumbnailUiState = + when { + taskData !is TaskData.Data -> Uninitialized + isLiveTile -> createLiveTileState(taskData, hasHeader) + isBackgroundOnly(taskData) -> BackgroundOnly(taskData.backgroundColor) + isSnapshotSplash(taskData) -> + SnapshotSplash(createSnapshotState(taskData, hasHeader), taskData.icon) + else -> Uninitialized + } + + private fun createSnapshotState(taskData: TaskData.Data, hasHeader: Boolean): Snapshot = + if (canHeaderBeCreated(taskData, hasHeader)) { + Snapshot.WithHeader( + taskData.thumbnailData?.thumbnail!!, + taskData.thumbnailData.rotation, + taskData.backgroundColor, + ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!), + ) + } else { + Snapshot.WithoutHeader( + taskData.thumbnailData?.thumbnail!!, + taskData.thumbnailData.rotation, + taskData.backgroundColor, + ) + } + + private fun isBackgroundOnly(taskData: TaskData.Data) = + taskData.isLocked || taskData.thumbnailData == null + + private fun isSnapshotSplash(taskData: TaskData.Data) = + taskData.thumbnailData?.thumbnail != null && !taskData.isLocked + + private fun canHeaderBeCreated(taskData: TaskData.Data, hasHeader: Boolean) = + hasHeader && taskData.icon != null && taskData.titleDescription != null + + private fun createLiveTileState(taskData: TaskData.Data, hasHeader: Boolean) = + if (canHeaderBeCreated(taskData, hasHeader)) { + // TODO(http://b/353965691): figure out what to do when `icon` or `titleDescription` is + // null. + LiveTile.WithHeader(ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!)) + } else LiveTile.WithoutHeader +} diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt index 5f98479349..54b2389a33 100644 --- a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt +++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskTileUiState.kt @@ -30,28 +30,36 @@ import com.android.systemui.shared.recents.model.ThumbnailData * @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) +data class TaskTileUiState( + val tasks: List, + val isLiveTile: Boolean, + val hasHeader: Boolean, +) + +sealed class TaskData { + abstract val taskId: Int -sealed interface TaskData { /** When no data was found for the TaskId provided */ - data class NoData(val taskId: Int) : TaskData + data class NoData(override 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 titleDescription App content description * @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, + override val taskId: Int, + val title: String?, + val titleDescription: String?, val icon: Drawable?, val thumbnailData: ThumbnailData?, val backgroundColor: Int, val isLocked: Boolean, - ) : TaskData + ) : 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 index 2e51a8a8cb..b2806f011d 100644 --- a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt +++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/TaskViewModel.kt @@ -16,12 +16,15 @@ package com.android.quickstep.recents.ui.viewmodel +import android.annotation.ColorInt import android.util.Log +import androidx.core.graphics.ColorUtils 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 com.android.quickstep.views.TaskViewType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +40,7 @@ import kotlinx.coroutines.flow.map */ @OptIn(ExperimentalCoroutinesApi::class) class TaskViewModel( + private val taskViewType: TaskViewType, recentsViewData: RecentsViewData, private val getTaskUseCase: GetTaskUseCase, dispatcherProvider: DispatcherProvider, @@ -62,7 +66,14 @@ class TaskViewModel( ::mapToUiState, ) } - .combine(isLiveTile) { tasks, isLiveTile -> TaskTileUiState(tasks, isLiveTile) } + .combine(isLiveTile) { tasks, isLiveTile -> + TaskTileUiState( + tasks = tasks, + isLiveTile = isLiveTile, + hasHeader = taskViewType == TaskViewType.DESKTOP, + ) + } + .distinctUntilChanged() .flowOn(dispatcherProvider.background) fun bind(vararg taskId: TaskId) { @@ -78,13 +89,16 @@ class TaskViewModel( TaskData.Data( taskId = taskId, title = result.title, + titleDescription = result.titleDescription, icon = result.icon, thumbnailData = result.thumbnail, - backgroundColor = result.backgroundColor, + backgroundColor = result.backgroundColor.removeAlpha(), isLocked = result.isLocked, ) } ?: TaskData.NoData(taskId) + @ColorInt private fun Int.removeAlpha(): Int = ColorUtils.setAlphaComponent(this, 0xff) + private companion object { const val TAG = "TaskViewModel" } diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt index 02baa3901a..a31b9c9084 100644 --- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt +++ b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailView.kt @@ -108,21 +108,6 @@ class TaskThumbnailView : FrameLayout, ViewPool.Reusable { viewData = RecentsDependencies.get(this) updateViewDataValues() viewModel = RecentsDependencies.get(this) - viewModel.uiState - .dropWhile { it == Uninitialized } - .flowOn(dispatcherProvider.background) - .onEach { viewModelUiState -> - Log.d(TAG, "viewModelUiState changed from: $uiState to: $viewModelUiState") - uiState = viewModelUiState - resetViews() - when (viewModelUiState) { - is Uninitialized -> {} - is LiveTile -> drawLiveWindow(viewModelUiState) - is SnapshotSplash -> drawSnapshotSplash(viewModelUiState) - is BackgroundOnly -> drawBackground(viewModelUiState.backgroundColor) - } - } - .launchIn(viewAttachedScope) viewModel.dimProgress .dropWhile { it == 0f } .flowOn(dispatcherProvider.background) @@ -166,6 +151,19 @@ class TaskThumbnailView : FrameLayout, ViewPool.Reusable { } } + fun setState(state: TaskThumbnailUiState) { + Log.d(TAG, "viewModelUiState changed from: $uiState to: $state") + if (uiState == state) return + uiState = state + resetViews() + when (state) { + is Uninitialized -> {} + is LiveTile -> drawLiveWindow(state) + is SnapshotSplash -> drawSnapshotSplash(state) + is BackgroundOnly -> drawBackground(state.backgroundColor) + } + } + private fun updateViewDataValues() { viewData.width.value = width viewData.height.value = height @@ -238,10 +236,6 @@ class TaskThumbnailView : FrameLayout, ViewPool.Reusable { thumbnailView.imageMatrix = viewModel.getThumbnailPositionState(width, height, isLayoutRtl) } - private companion object { - const val TAG = "TaskThumbnailView" - } - private fun maybeCreateHeader() { if (enableDesktopExplodedView() && taskThumbnailViewHeader == null) { taskThumbnailViewHeader = @@ -251,4 +245,8 @@ class TaskThumbnailView : FrameLayout, ViewPool.Reusable { addView(taskThumbnailViewHeader) } } + + private companion object { + const val TAG = "TaskThumbnailView" + } } diff --git a/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModel.kt b/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModel.kt index a048a1db46..a9fdaa53ae 100644 --- a/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModel.kt +++ b/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModel.kt @@ -17,7 +17,6 @@ package com.android.quickstep.task.viewmodel import android.graphics.Matrix -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState import kotlinx.coroutines.flow.Flow /** ViewModel for representing TaskThumbnails */ @@ -28,9 +27,6 @@ interface TaskThumbnailViewModel { /** Provides the alpha of the splash icon */ val splashAlpha: Flow - /** Provides the UiState by which the task thumbnail can be represented */ - val uiState: Flow - /** Attaches this ViewModel to a specific task id for it to provide data from. */ fun bind(taskId: Int) diff --git a/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModelImpl.kt b/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModelImpl.kt index a154c3cba7..4e4e2256c7 100644 --- a/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModelImpl.kt +++ b/quickstep/src/com/android/quickstep/task/viewmodel/TaskThumbnailViewModelImpl.kt @@ -16,50 +16,31 @@ package com.android.quickstep.task.viewmodel -import android.annotation.ColorInt import android.app.ActivityTaskManager.INVALID_TASK_ID -import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM import android.graphics.Matrix import android.util.Log -import androidx.core.graphics.ColorUtils -import com.android.launcher3.Flags.enableDesktopExplodedView import com.android.launcher3.util.coroutines.DispatcherProvider -import com.android.quickstep.recents.data.RecentTasksRepository -import com.android.quickstep.recents.data.RecentsDeviceProfileRepository import com.android.quickstep.recents.usecase.GetThumbnailPositionUseCase import com.android.quickstep.recents.usecase.ThumbnailPositionState import com.android.quickstep.recents.viewmodel.RecentsViewData import com.android.quickstep.task.thumbnail.SplashAlphaUseCase -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.SnapshotSplash -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.ThumbnailHeader -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized -import com.android.systemui.shared.recents.model.Task import kotlin.math.max 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.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map @OptIn(ExperimentalCoroutinesApi::class) class TaskThumbnailViewModelImpl( recentsViewData: RecentsViewData, taskContainerData: TaskContainerData, dispatcherProvider: DispatcherProvider, - private val tasksRepository: RecentTasksRepository, - private val deviceProfileRepository: RecentsDeviceProfileRepository, private val getThumbnailPositionUseCase: GetThumbnailPositionUseCase, private val splashAlphaUseCase: SplashAlphaUseCase, ) : TaskThumbnailViewModel { - private val task = MutableStateFlow>(flowOf(null)) private val splashProgress = MutableStateFlow(flowOf(0f)) private var taskId: Int = INVALID_TASK_ID @@ -74,42 +55,9 @@ class TaskThumbnailViewModelImpl( override val splashAlpha = splashProgress.flatMapLatest { it }.flowOn(dispatcherProvider.background) - private val isLiveTile = - combine( - task.flatMapLatest { it }.map { it?.key?.id }.distinctUntilChanged(), - recentsViewData.runningTaskIds, - recentsViewData.runningTaskShowScreenshot, - ) { taskId, runningTaskIds, runningTaskShowScreenshot -> - runningTaskIds.contains(taskId) && !runningTaskShowScreenshot - } - .distinctUntilChanged() - - override val uiState: Flow = - combine(task.flatMapLatest { it }, isLiveTile) { taskVal, isRunning -> - // TODO(b/369339561) This log is firing a lot. Reduce emissions from TasksRepository - // then re-enable this log. - // Log.d( - // TAG, - // "Received task and / or live tile update. taskVal: $taskVal" - // + " isRunning: $isRunning.", - // ) - when { - taskVal == null -> Uninitialized - isRunning -> createLiveTileState(taskVal) - isBackgroundOnly(taskVal) -> - BackgroundOnly(taskVal.colorBackground.removeAlpha()) - isSnapshotSplashState(taskVal) -> - SnapshotSplash(createSnapshotState(taskVal), taskVal.icon) - else -> Uninitialized - } - } - .distinctUntilChanged() - .flowOn(dispatcherProvider.background) - override fun bind(taskId: Int) { Log.d(TAG, "bind taskId: $taskId") this.taskId = taskId - task.value = tasksRepository.getTaskDataById(taskId) splashProgress.value = splashAlphaUseCase.execute(taskId) } @@ -122,62 +70,6 @@ class TaskThumbnailViewModelImpl( is ThumbnailPositionState.MissingThumbnail -> Matrix.IDENTITY_MATRIX } - private fun isBackgroundOnly(task: Task): Boolean = task.isLocked || task.thumbnail == null - - private fun isSnapshotSplashState(task: Task): Boolean { - val thumbnailPresent = task.thumbnail?.thumbnail != null - val taskLocked = task.isLocked - - return thumbnailPresent && !taskLocked - } - - private fun createSnapshotState(task: Task): Snapshot { - val thumbnailData = task.thumbnail - val bitmap = thumbnailData?.thumbnail!! - var thumbnailHeader = maybeCreateHeader(task) - return if (thumbnailHeader != null) - Snapshot.WithHeader( - bitmap, - thumbnailData.rotation, - task.colorBackground.removeAlpha(), - thumbnailHeader, - ) - else - Snapshot.WithoutHeader( - bitmap, - thumbnailData.rotation, - task.colorBackground.removeAlpha(), - ) - } - - private fun shouldHaveThumbnailHeader(task: Task): Boolean { - return deviceProfileRepository.getRecentsDeviceProfile().canEnterDesktopMode && - enableDesktopExplodedView() && - task.key.windowingMode == WINDOWING_MODE_FREEFORM - } - - private fun maybeCreateHeader(task: Task): ThumbnailHeader? { - // Header is only needed when this task is a desktop task and Overivew exploded view is - // enabled. - if (!shouldHaveThumbnailHeader(task)) { - return null - } - - // TODO(http://b/353965691): figure out what to do when `icon` or `titleDescription` is - // null. - val icon = task.icon ?: return null - val titleDescription = task.titleDescription ?: return null - return ThumbnailHeader(icon, titleDescription) - } - - private fun createLiveTileState(task: Task): LiveTile { - val thumbnailHeader = maybeCreateHeader(task) - return if (thumbnailHeader != null) LiveTile.WithHeader(thumbnailHeader) - else LiveTile.WithoutHeader - } - - @ColorInt private fun Int.removeAlpha(): Int = ColorUtils.setAlphaComponent(this, 0xff) - private companion object { const val MAX_SCRIM_ALPHA = 0.4f const val TAG = "TaskThumbnailViewModel" diff --git a/quickstep/src/com/android/quickstep/views/TaskContainer.kt b/quickstep/src/com/android/quickstep/views/TaskContainer.kt index 7ac0946e17..6b5d8dd0ea 100644 --- a/quickstep/src/com/android/quickstep/views/TaskContainer.kt +++ b/quickstep/src/com/android/quickstep/views/TaskContainer.kt @@ -28,6 +28,8 @@ import com.android.quickstep.recents.di.RecentsDependencies import com.android.quickstep.recents.di.get import com.android.quickstep.recents.di.getScope import com.android.quickstep.recents.di.inject +import com.android.quickstep.recents.ui.mapper.TaskUiStateMapper +import com.android.quickstep.recents.ui.viewmodel.TaskData import com.android.quickstep.recents.viewmodel.TaskContainerViewModel import com.android.quickstep.task.thumbnail.TaskThumbnailView import com.android.quickstep.task.viewmodel.TaskContainerData @@ -163,4 +165,8 @@ class TaskContainer( digitalWellBeingToast?.let { addAccessibleChildToList(it, outChildren) } overlay.addChildForAccessibility(outChildren) } + + fun setState(state: TaskData?, liveTile: Boolean, hasHeader: Boolean) { + thumbnailView.setState(TaskUiStateMapper.toTaskThumbnailUiState(state, liveTile, hasHeader)) + } } diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt index e7a395f278..741297db64 100644 --- a/quickstep/src/com/android/quickstep/views/TaskView.kt +++ b/quickstep/src/com/android/quickstep/views/TaskView.kt @@ -70,6 +70,7 @@ import com.android.launcher3.util.SplitConfigurationOptions.StagePosition import com.android.launcher3.util.TraceHelper import com.android.launcher3.util.TransformingTouchDelegate import com.android.launcher3.util.ViewPool +import com.android.launcher3.util.coroutines.DispatcherProvider import com.android.launcher3.util.rects.set import com.android.quickstep.FullscreenDrawParams import com.android.quickstep.RecentsModel @@ -77,6 +78,11 @@ import com.android.quickstep.RemoteAnimationTargets import com.android.quickstep.TaskOverlayFactory import com.android.quickstep.TaskViewUtils import com.android.quickstep.orientation.RecentsPagedOrientationHandler +import com.android.quickstep.recents.di.RecentsDependencies +import com.android.quickstep.recents.di.get +import com.android.quickstep.recents.di.inject +import com.android.quickstep.recents.ui.viewmodel.TaskTileUiState +import com.android.quickstep.recents.ui.viewmodel.TaskViewModel import com.android.quickstep.util.ActiveGestureErrorDetector import com.android.quickstep.util.ActiveGestureLog import com.android.quickstep.util.BorderAnimator @@ -88,6 +94,12 @@ import com.android.quickstep.views.RecentsView.UNBOUND_TASK_VIEW_ID import com.android.systemui.shared.recents.model.Task import com.android.systemui.shared.recents.model.ThumbnailData import com.android.systemui.shared.system.ActivityManagerWrapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch /** A task in the Recents view. */ open class TaskView @@ -448,6 +460,11 @@ constructor( private val settledProgressDismiss = settledProgressPropertyFactory.get(SETTLED_PROGRESS_INDEX_DISMISS) + private var viewModel: TaskViewModel? = null + private val dispatcherProvider: DispatcherProvider by RecentsDependencies.inject() + private val coroutineScope by lazy { CoroutineScope(SupervisorJob() + dispatcherProvider.main) } + private val coroutineJobs = mutableListOf() + /** * Returns an animator of [settledProgressDismiss] that transition in with a built-in * interpolator. @@ -601,6 +618,8 @@ constructor( override fun onRecycle() { resetPersistentViewTransforms() + + viewModel = null attachAlpha = 1f splitAlpha = 1f // Clear any references to the thumbnail (it will be re-read either from the cache or the @@ -715,6 +734,44 @@ constructor( ?.inflate() } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (enableRefactorTaskThumbnail()) { + // The TaskView lifecycle is starts the ViewModel during onBind, and cleans it in + // onRecycle. So it should be initialized at this point. TaskView Lifecycle: + // `bind` -> `onBind` -> onAttachedToWindow() -> onDetachFromWindow -> onRecycle + coroutineJobs += + coroutineScope.launch { + viewModel!!.state.collectLatest(::updateTaskContainerState) + } + } + } + + private fun updateTaskContainerState(state: TaskTileUiState) { + val mapOfTasks = state.tasks.associateBy { it.taskId } + taskContainers.forEach { container -> + container.setState( + state = mapOfTasks[container.task.key.id], + liveTile = state.isLiveTile, + hasHeader = type == TaskViewType.DESKTOP, + ) + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + if (enableRefactorTaskThumbnail()) { + // The jobs are being cancelled in the background thread. So we make a copy of the list + // to prevent cleaning a new job that might be added to this list during onAttach + // or another moment in the lifecycle. + val coroutineJobsToCancel = coroutineJobs.toList() + coroutineJobs.clear() + coroutineScope.launch(dispatcherProvider.background) { + coroutineJobsToCancel.forEach { it.cancel("TaskView detaching from window") } + } + } + } + /** Updates this task view to the given {@param task}. */ open fun bind( task: Task, @@ -738,6 +795,17 @@ constructor( } open fun onBind(orientedState: RecentsOrientedState) { + if (enableRefactorTaskThumbnail()) { + viewModel = + TaskViewModel( + taskViewType = type, + recentsViewData = RecentsDependencies.get(), + getTaskUseCase = RecentsDependencies.get(), + dispatcherProvider = RecentsDependencies.get(), + ) + .apply { bind(*taskIds) } + } + taskContainers.forEach { it.bind() if (enableRefactorTaskThumbnail()) { diff --git a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/FakeTaskThumbnailViewModel.kt b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/FakeTaskThumbnailViewModel.kt index 47d2bfcef9..e033e7b97e 100644 --- a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/FakeTaskThumbnailViewModel.kt +++ b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/FakeTaskThumbnailViewModel.kt @@ -17,14 +17,12 @@ package com.android.quickstep.task.thumbnail import android.graphics.Matrix -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized import com.android.quickstep.task.viewmodel.TaskThumbnailViewModel import kotlinx.coroutines.flow.MutableStateFlow class FakeTaskThumbnailViewModel : TaskThumbnailViewModel { override val dimProgress = MutableStateFlow(0f) override val splashAlpha = MutableStateFlow(0f) - override val uiState = MutableStateFlow(Uninitialized) override fun bind(taskId: Int) { // no-op diff --git a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewScreenshotTest.kt b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewScreenshotTest.kt index a76f83c22c..3b28afd160 100644 --- a/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewScreenshotTest.kt +++ b/quickstep/tests/multivalentScreenshotTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewScreenshotTest.kt @@ -60,36 +60,27 @@ class TaskThumbnailViewScreenshotTest(emulationSpec: DeviceEmulationSpec) { screenshotRule.screenshotTest("taskThumbnailView_uninitialized") { activity -> activity.actionBar?.hide() val taskThumbnailView = createTaskThumbnailView(activity) - taskThumbnailViewModel.uiState.value = BackgroundOnly(Color.YELLOW) - taskThumbnailViewModel.uiState.value = Uninitialized + taskThumbnailView.setState(Uninitialized) taskThumbnailView } } @Test fun taskThumbnailView_recyclesToUninitialized() { - screenshotRule.screenshotTest( - "taskThumbnailView_uninitialized", - viewProvider = { activity -> - activity.actionBar?.hide() - val taskThumbnailView = createTaskThumbnailView(activity) - taskThumbnailViewModel.uiState.value = BackgroundOnly(Color.YELLOW) - taskThumbnailView - }, - checkView = { _, taskThumbnailView -> - // Call onRecycle() after View is attached (end of block above) - (taskThumbnailView as TaskThumbnailView).onRecycle() - false - }, - ) + screenshotRule.screenshotTest("taskThumbnailView_uninitialized") { activity -> + activity.actionBar?.hide() + val taskThumbnailView = createTaskThumbnailView(activity) + taskThumbnailView.setState(BackgroundOnly(Color.YELLOW)) + taskThumbnailView.onRecycle() + taskThumbnailView + } } @Test fun taskThumbnailView_backgroundOnly() { screenshotRule.screenshotTest("taskThumbnailView_backgroundOnly") { activity -> activity.actionBar?.hide() - taskThumbnailViewModel.uiState.value = BackgroundOnly(Color.YELLOW) - createTaskThumbnailView(activity) + createTaskThumbnailView(activity).apply { setState(BackgroundOnly(Color.YELLOW)) } } } diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt new file mode 100644 index 0000000000..124045fd69 --- /dev/null +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt @@ -0,0 +1,231 @@ +/* + * 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.mapper + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import android.platform.test.annotations.EnableFlags +import android.view.Surface +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.launcher3.Flags +import com.android.quickstep.recents.ui.viewmodel.TaskData +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot +import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.ThumbnailHeader +import com.android.systemui.shared.recents.model.ThumbnailData +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TaskUiStateMapperTest { + + @Test + fun taskData_isNull_returns_Uninitialized() { + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = null, + isLiveTile = false, + hasHeader = false, + ) + assertThat(result).isEqualTo(TaskThumbnailUiState.Uninitialized) + } + + @Test + fun taskData_isLiveTile_returns_LiveTile() { + val inputs = + listOf(TASK_DATA, TASK_DATA.copy(thumbnailData = null), TASK_DATA.copy(isLocked = true)) + inputs.forEach { input -> + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = input, + isLiveTile = true, + hasHeader = false, + ) + assertThat(result).isEqualTo(LiveTile.WithoutHeader) + } + } + + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) + @Test + fun taskData_isLiveTileWithHeader_returns_LiveTileWithHeader() { + val inputs = + listOf( + TASK_DATA, + TASK_DATA.copy(thumbnailData = null), + TASK_DATA.copy(isLocked = true), + TASK_DATA.copy(title = null), + ) + val expected = + LiveTile.WithHeader(header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION)) + inputs.forEach { taskData -> + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = taskData, + isLiveTile = true, + hasHeader = true, + ) + assertThat(result).isEqualTo(expected) + } + } + + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) + @Test + fun taskData_isLiveTileWithHeader_missingHeaderData_returns_LiveTileWithoutHeader() { + val inputs = + listOf( + TASK_DATA.copy(icon = null), + TASK_DATA.copy(titleDescription = null), + TASK_DATA.copy(icon = null, titleDescription = null), + ) + + inputs.forEach { taskData -> + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = taskData, + isLiveTile = true, + hasHeader = true, + ) + assertThat(result).isEqualTo(LiveTile.WithoutHeader) + } + } + + @Test + fun taskData_isStaticTile_returns_SnapshotSplash() { + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = TASK_DATA, + isLiveTile = false, + hasHeader = false, + ) + + val expected = + TaskThumbnailUiState.SnapshotSplash( + snapshot = + Snapshot.WithoutHeader( + backgroundColor = TASK_BACKGROUND_COLOR, + bitmap = TASK_THUMBNAIL, + thumbnailRotation = Surface.ROTATION_0, + ), + splash = TASK_ICON, + ) + + assertThat(result).isEqualTo(expected) + } + + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) + @Test + fun taskData_isStaticTile_withHeader_returns_SnapshotSplashWithHeader() { + val inputs = listOf(TASK_DATA, TASK_DATA.copy(title = null)) + val expected = + TaskThumbnailUiState.SnapshotSplash( + snapshot = + Snapshot.WithHeader( + backgroundColor = TASK_BACKGROUND_COLOR, + bitmap = TASK_THUMBNAIL, + thumbnailRotation = Surface.ROTATION_0, + header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION), + ), + splash = TASK_ICON, + ) + inputs.forEach { taskData -> + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = taskData, + isLiveTile = false, + hasHeader = true, + ) + assertThat(result).isEqualTo(expected) + } + } + + @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) + @Test + fun taskData_isStaticTile_missingHeaderData_returns_SnapshotSplashWithoutHeader() { + val inputs = + listOf( + TASK_DATA.copy(titleDescription = null, icon = null), + TASK_DATA.copy(titleDescription = null), + TASK_DATA.copy(icon = null), + ) + val expected = + Snapshot.WithoutHeader( + backgroundColor = TASK_BACKGROUND_COLOR, + thumbnailRotation = Surface.ROTATION_0, + bitmap = TASK_THUMBNAIL, + ) + inputs.forEach { taskData -> + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = taskData, + isLiveTile = false, + hasHeader = true, + ) + + assertThat(result).isInstanceOf(TaskThumbnailUiState.SnapshotSplash::class.java) + result as TaskThumbnailUiState.SnapshotSplash + assertThat(result.snapshot).isEqualTo(expected) + } + } + + @Test + fun taskData_thumbnailIsNull_returns_BackgroundOnly() { + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = TASK_DATA.copy(thumbnailData = null), + isLiveTile = false, + hasHeader = false, + ) + + val expected = TaskThumbnailUiState.BackgroundOnly(TASK_BACKGROUND_COLOR) + assertThat(result).isEqualTo(expected) + } + + @Test + fun taskData_isLocked_returns_BackgroundOnly() { + val result = + TaskUiStateMapper.toTaskThumbnailUiState( + taskData = TASK_DATA.copy(isLocked = true), + isLiveTile = false, + hasHeader = false, + ) + + val expected = TaskThumbnailUiState.BackgroundOnly(TASK_BACKGROUND_COLOR) + assertThat(result).isEqualTo(expected) + } + + private companion object { + const val TASK_TITLE_DESCRIPTION = "Title Description 1" + val TASK_ICON = ShapeDrawable() + val TASK_THUMBNAIL = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + val TASK_THUMBNAIL_DATA = + ThumbnailData(thumbnail = TASK_THUMBNAIL, rotation = Surface.ROTATION_0) + val TASK_BACKGROUND_COLOR = Color.rgb(1, 2, 3) + val TASK_DATA = + TaskData.Data( + 1, + title = "Task 1", + titleDescription = TASK_TITLE_DESCRIPTION, + icon = TASK_ICON, + thumbnailData = TASK_THUMBNAIL_DATA, + backgroundColor = TASK_BACKGROUND_COLOR, + 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 index 54a27e9062..7a4b5f2b73 100644 --- 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 @@ -23,6 +23,7 @@ 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.quickstep.views.TaskViewType import com.android.systemui.shared.recents.model.ThumbnailData import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,15 +46,17 @@ class TaskViewModelTest { private val recentsViewData = RecentsViewData() private val getTaskUseCase = mock() - private val sut = - TaskViewModel( - recentsViewData = recentsViewData, - getTaskUseCase = getTaskUseCase, - dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher), - ) + private lateinit var sut: TaskViewModel @Before fun setUp() { + sut = + TaskViewModel( + taskViewType = TaskViewType.SINGLE, + recentsViewData = recentsViewData, + getTaskUseCase = getTaskUseCase, + dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher), + ) 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) }) @@ -65,10 +68,38 @@ class TaskViewModelTest { fun singleTaskRetrieved_when_validTaskId() = testScope.runTest { sut.bind(TASK_MODEL_1.id) - val expectedResult = TaskTileUiState(listOf(TASK_MODEL_1.toUiState()), false) + val expectedResult = + TaskTileUiState( + tasks = listOf(TASK_MODEL_1.toUiState()), + isLiveTile = false, + hasHeader = false, + ) assertThat(sut.state.first()).isEqualTo(expectedResult) } + @Test + fun hasHeader_when_taskViewTypeIsDesktop() = + testScope.runTest { + val expectedResults = + mapOf( + TaskViewType.SINGLE to false, + TaskViewType.GROUPED to false, + TaskViewType.DESKTOP to true, + ) + + expectedResults.forEach { (type, expectedResult) -> + sut = + TaskViewModel( + taskViewType = type, + recentsViewData = recentsViewData, + getTaskUseCase = getTaskUseCase, + dispatcherProvider = TestDispatcherProvider(unconfinedTestDispatcher), + ) + sut.bind(TASK_MODEL_1.id) + assertThat(sut.state.first().hasHeader).isEqualTo(expectedResult) + } + } + @Test fun multipleTasksRetrieved_when_validTaskIds() = testScope.runTest { @@ -83,6 +114,7 @@ class TaskViewModelTest { TaskData.NoData(INVALID_TASK_ID), ), isLiveTile = false, + hasHeader = false, ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -103,6 +135,7 @@ class TaskViewModelTest { TASK_MODEL_3.toUiState(), ), isLiveTile = true, + hasHeader = false, ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -123,6 +156,7 @@ class TaskViewModelTest { TASK_MODEL_3.toUiState(), ), isLiveTile = false, + hasHeader = false, ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -142,6 +176,7 @@ class TaskViewModelTest { TASK_MODEL_3.toUiState(), ), isLiveTile = false, + hasHeader = false, ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -157,6 +192,7 @@ class TaskViewModelTest { TaskTileUiState( tasks = listOf(TASK_MODEL_1.toUiState(), TASK_MODEL_2.toUiState()), isLiveTile = false, + hasHeader = false, ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -166,7 +202,11 @@ class TaskViewModelTest { testScope.runTest { sut.bind(INVALID_TASK_ID) val expectedResult = - TaskTileUiState(listOf(TaskData.NoData(INVALID_TASK_ID)), isLiveTile = false) + TaskTileUiState( + listOf(TaskData.NoData(INVALID_TASK_ID)), + isLiveTile = false, + hasHeader = false, + ) assertThat(sut.state.first()).isEqualTo(expectedResult) } @@ -174,6 +214,7 @@ class TaskViewModelTest { TaskData.Data( taskId = id, title = title, + titleDescription = titleDescription, icon = icon!!, thumbnailData = thumbnail, backgroundColor = backgroundColor, diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelImplTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelImplTest.kt index a956c9c969..22636b0126 100644 --- a/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelImplTest.kt +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/task/thumbnail/TaskThumbnailViewModelImplTest.kt @@ -16,37 +16,16 @@ package com.android.quickstep.task.thumbnail -import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM -import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN -import android.content.ComponentName -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Color import android.graphics.Matrix -import android.graphics.drawable.Drawable -import android.platform.test.annotations.EnableFlags import android.platform.test.flag.junit.SetFlagsRule -import android.view.Surface import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.android.launcher3.Flags import com.android.launcher3.util.TestDispatcherProvider -import com.android.quickstep.recents.data.FakeRecentsDeviceProfileRepository -import com.android.quickstep.recents.data.FakeTasksRepository -import com.android.quickstep.recents.data.RecentsDeviceProfile import com.android.quickstep.recents.usecase.GetThumbnailPositionUseCase import com.android.quickstep.recents.usecase.ThumbnailPositionState.MatrixScaling import com.android.quickstep.recents.usecase.ThumbnailPositionState.MissingThumbnail import com.android.quickstep.recents.viewmodel.RecentsViewData -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.LiveTile -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Snapshot -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.SnapshotSplash -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.ThumbnailHeader -import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.Uninitialized import com.android.quickstep.task.viewmodel.TaskContainerData import com.android.quickstep.task.viewmodel.TaskThumbnailViewModelImpl -import com.android.systemui.shared.recents.model.Task -import com.android.systemui.shared.recents.model.ThumbnailData import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher @@ -69,8 +48,6 @@ class TaskThumbnailViewModelImplTest { private val recentsViewData = RecentsViewData() private val taskContainerData = TaskContainerData() private val dispatcherProvider = TestDispatcherProvider(dispatcher) - private val tasksRepository = FakeTasksRepository() - private val deviceProfileRepository = FakeRecentsDeviceProfileRepository() private val mGetThumbnailPositionUseCase = mock() private val splashAlphaUseCase: SplashAlphaUseCase = mock() @@ -79,208 +56,11 @@ class TaskThumbnailViewModelImplTest { recentsViewData, taskContainerData, dispatcherProvider, - tasksRepository, - deviceProfileRepository, mGetThumbnailPositionUseCase, splashAlphaUseCase, ) } - private val fullscreenTaskIdRange: IntRange = 0..5 - private val freeformTaskIdRange: IntRange = 6..10 - - private val fullscreenTasks = fullscreenTaskIdRange.map(::createTaskWithId) - private val freeformTasks = freeformTaskIdRange.map(::createFreeformTaskWithId) - private val tasks = fullscreenTasks + freeformTasks - - @Test - fun initialStateIsUninitialized() = - testScope.runTest { assertThat(systemUnderTest.uiState.first()).isEqualTo(Uninitialized) } - - @Test - fun bindRunningTask_thenStateIs_LiveTile() = - testScope.runTest { - val taskId = 1 - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - recentsViewData.runningTaskIds.value = setOf(taskId) - systemUnderTest.bind(taskId) - - assertThat(systemUnderTest.uiState.first()).isEqualTo(LiveTile.WithoutHeader) - } - - @Test - fun bindRunningTaskShouldShowScreenshot_thenStateIs_SnapshotSplash() = - testScope.runTest { - val taskId = 1 - val expectedThumbnailData = createThumbnailData() - tasksRepository.seedThumbnailData(mapOf(taskId to expectedThumbnailData)) - val expectedIconData = mock() - tasksRepository.seedIconData(taskId, "Task $taskId", "", expectedIconData) - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - recentsViewData.runningTaskIds.value = setOf(taskId) - recentsViewData.runningTaskShowScreenshot.value = true - systemUnderTest.bind(taskId) - - assertThat(systemUnderTest.uiState.first()) - .isEqualTo( - SnapshotSplash( - Snapshot.WithoutHeader( - backgroundColor = Color.rgb(1, 1, 1), - bitmap = expectedThumbnailData.thumbnail!!, - thumbnailRotation = Surface.ROTATION_0, - ), - expectedIconData, - ) - ) - } - - @Test - fun bindRunningTaskThenStoppedTaskWithoutThumbnail_thenStateChangesToBackgroundOnly() = - testScope.runTest { - val runningTaskId = 1 - val stoppedTaskId = 2 - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(runningTaskId, stoppedTaskId)) - recentsViewData.runningTaskIds.value = setOf(runningTaskId) - systemUnderTest.bind(runningTaskId) - assertThat(systemUnderTest.uiState.first()).isEqualTo(LiveTile.WithoutHeader) - - systemUnderTest.bind(stoppedTaskId) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo(BackgroundOnly(backgroundColor = Color.rgb(2, 2, 2))) - } - - @Test - fun bindStoppedTaskWithoutThumbnail_thenStateIs_BackgroundOnly_withAlphaRemoved() = - testScope.runTest { - val stoppedTaskId = 2 - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(stoppedTaskId)) - - systemUnderTest.bind(stoppedTaskId) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo(BackgroundOnly(backgroundColor = Color.rgb(2, 2, 2))) - } - - @Test - fun bindLockedTaskWithThumbnail_thenStateIs_BackgroundOnly() = - testScope.runTest { - val taskId = 2 - tasksRepository.seedThumbnailData(mapOf(taskId to createThumbnailData())) - tasks[taskId].isLocked = true - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - - systemUnderTest.bind(taskId) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo(BackgroundOnly(backgroundColor = Color.rgb(2, 2, 2))) - } - - @Test - fun bindStoppedTaskWithThumbnail_thenStateIs_SnapshotSplash_withAlphaRemoved() = - testScope.runTest { - val taskId = 2 - val expectedThumbnailData = createThumbnailData(rotation = Surface.ROTATION_270) - tasksRepository.seedThumbnailData(mapOf(taskId to expectedThumbnailData)) - val expectedIconData = mock() - tasksRepository.seedIconData(taskId, "Task $taskId", "", expectedIconData) - tasksRepository.seedTasks(tasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - - systemUnderTest.bind(taskId) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo( - SnapshotSplash( - Snapshot.WithoutHeader( - backgroundColor = Color.rgb(2, 2, 2), - bitmap = expectedThumbnailData.thumbnail!!, - thumbnailRotation = Surface.ROTATION_270, - ), - expectedIconData, - ) - ) - } - - @Test - fun bindNonVisibleStoppedTask_whenMadeVisible_thenStateIsSnapshotSplash() = - testScope.runTest { - val taskId = 2 - val expectedThumbnailData = createThumbnailData() - tasksRepository.seedThumbnailData(mapOf(taskId to expectedThumbnailData)) - val expectedIconData = mock() - tasksRepository.seedIconData(taskId, "Task $taskId", "", expectedIconData) - tasksRepository.seedTasks(tasks) - - systemUnderTest.bind(taskId) - assertThat(systemUnderTest.uiState.first()).isEqualTo(Uninitialized) - - tasksRepository.setVisibleTasks(setOf(taskId)) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo( - SnapshotSplash( - Snapshot.WithoutHeader( - backgroundColor = Color.rgb(2, 2, 2), - bitmap = expectedThumbnailData.thumbnail!!, - thumbnailRotation = Surface.ROTATION_0, - ), - expectedIconData, - ) - ) - } - - @Test - @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) - fun bindRunningTask_inDesktop_thenStateIs_LiveTile_withHeader() = - testScope.runTest { - deviceProfileRepository.setRecentsDeviceProfile( - RecentsDeviceProfile(isLargeScreen = true, canEnterDesktopMode = true) - ) - - val taskId = freeformTaskIdRange.first - val expectedIconData = mock() - tasksRepository.seedIconData(taskId, "Task $taskId", "Task $taskId", expectedIconData) - tasksRepository.seedTasks(freeformTasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - recentsViewData.runningTaskIds.value = setOf(taskId) - systemUnderTest.bind(taskId) - - assertThat(systemUnderTest.uiState.first()) - .isEqualTo(LiveTile.WithHeader(ThumbnailHeader(expectedIconData, "Task $taskId"))) - } - - @Test - @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_EXPLODED_VIEW) - fun bindStoppedTaskWithThumbnail_inDesktop_thenStateIs_SnapshotSplash_withHeader() = - testScope.runTest { - deviceProfileRepository.setRecentsDeviceProfile( - RecentsDeviceProfile(isLargeScreen = true, canEnterDesktopMode = true) - ) - - val taskId = freeformTaskIdRange.first - val expectedThumbnailData = createThumbnailData(rotation = Surface.ROTATION_0) - tasksRepository.seedThumbnailData(mapOf(taskId to expectedThumbnailData)) - val expectedIconData = mock() - tasksRepository.seedIconData(taskId, "Task $taskId", "Task $taskId", expectedIconData) - tasksRepository.seedTasks(freeformTasks) - tasksRepository.setVisibleTasks(setOf(taskId)) - - systemUnderTest.bind(taskId) - assertThat(systemUnderTest.uiState.first()) - .isEqualTo( - SnapshotSplash( - Snapshot.WithHeader( - backgroundColor = Color.rgb(taskId, taskId, taskId), - bitmap = expectedThumbnailData.thumbnail!!, - thumbnailRotation = Surface.ROTATION_0, - header = ThumbnailHeader(expectedIconData, "Task $taskId"), - ), - expectedIconData, - ) - ) - } - @Test fun getSnapshotMatrix_MissingThumbnail() = testScope.runTest { @@ -337,51 +117,7 @@ class TaskThumbnailViewModelImplTest { assertThat(systemUnderTest.dimProgress.first()).isEqualTo(0f) } - private fun createTaskWithId(taskId: Int) = - Task( - Task.TaskKey( - taskId, - WINDOWING_MODE_FULLSCREEN, - Intent(), - ComponentName("", ""), - 0, - 2000, - ) - ) - .apply { - colorBackground = Color.argb(taskId, taskId, taskId, taskId) - titleDescription = "Task $taskId" - icon = mock() - } - - private fun createFreeformTaskWithId(taskId: Int) = - Task( - Task.TaskKey( - taskId, - WINDOWING_MODE_FREEFORM, - Intent(), - ComponentName("", ""), - 0, - 2000, - ) - ) - .apply { - colorBackground = Color.argb(taskId, taskId, taskId, taskId) - titleDescription = "Task $taskId" - icon = mock() - } - - private fun createThumbnailData(rotation: Int = Surface.ROTATION_0): ThumbnailData { - val bitmap = mock() - whenever(bitmap.width).thenReturn(THUMBNAIL_WIDTH) - whenever(bitmap.height).thenReturn(THUMBNAIL_HEIGHT) - - return ThumbnailData(thumbnail = bitmap, rotation = rotation) - } - - companion object { - const val THUMBNAIL_WIDTH = 100 - const val THUMBNAIL_HEIGHT = 200 + private companion object { const val CANVAS_WIDTH = 300 const val CANVAS_HEIGHT = 600 val MATRIX =