/* * Copyright (C) 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3.taskbar import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN import android.app.ActivityTaskManager import android.content.Context import android.util.Log import android.window.DesktopExperienceFlags import android.window.DesktopModeFlags import androidx.annotation.VisibleForTesting import com.android.launcher3.BubbleTextView.RunningAppState import com.android.launcher3.Flags import com.android.launcher3.Flags.enableRecentsInTaskbar import com.android.launcher3.Flags.enableTaskbarRecentsThemedIcons import com.android.launcher3.graphics.ThemeManager import com.android.launcher3.graphics.ThemeManager.ThemeChangeListener import com.android.launcher3.model.data.AppPairInfo import com.android.launcher3.model.data.ItemInfo import com.android.launcher3.model.data.TaskItemInfo import com.android.launcher3.model.data.WorkspaceItemInfo import com.android.launcher3.statehandlers.DesktopVisibilityController import com.android.launcher3.taskbar.TaskbarControllers.LoggableTaskbarController import com.android.launcher3.taskbar.TaskbarPopupController.canPinAppWithContextMenu import com.android.launcher3.util.CancellableTask import com.android.launcher3.util.Executors.MAIN_EXECUTOR import com.android.launcher3.util.SafeCloseable import com.android.quickstep.RecentsFilterState import com.android.quickstep.RecentsModel import com.android.quickstep.util.DesktopTask import com.android.quickstep.util.GroupTask import com.android.quickstep.util.SingleTask import com.android.quickstep.util.SplitTask import com.android.systemui.shared.recents.model.Task import com.android.wm.shell.shared.desktopmode.DesktopModeStatus import java.io.PrintWriter /** * Provides recent apps functionality, when the Taskbar Recent Apps section is enabled. Behavior: * - When in Fullscreen mode: show the N most recent Tasks * - When in Desktop Mode: show the currently running (open) Tasks */ class TaskbarRecentAppsController( private val context: Context, private val recentsModel: RecentsModel, private val themeManager: ThemeManager, ) : LoggableTaskbarController { var canShowRunningApps = DesktopModeStatus.canEnterDesktopMode(context) && DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_TASKBAR_RUNNING_APPS.isTrue @VisibleForTesting set(isEnabledFromTest) { field = isEnabledFromTest if (!field && !canShowRecentApps) { recentsModel.unregisterRecentTasksChangedListener(recentTasksChangedListener) } } val enableRecentTasksThrottle = DesktopExperienceFlags.ENABLE_TASKBAR_RECENT_TASKS_THROTTLE_BUGFIX.isTrue // TODO(b/343532825): Add a setting to disable Recents even when the flag is on. var canShowRecentApps = enableRecentsInTaskbar() @VisibleForTesting set(isEnabledFromTest) { field = isEnabledFromTest if (!field && !canShowRunningApps) { recentsModel.unregisterRecentTasksChangedListener(recentTasksChangedListener) } } // Initialized in init. private lateinit var controllers: TaskbarControllers var shownHotseatItems: List = emptyList() private set private var mostRecentTaskId = ActivityTaskManager.INVALID_TASK_ID private var previousDeskId = DesktopVisibilityController.INACTIVE_DESK_ID private var allRecentTasks: List = emptyList() private var desktopTasks: List = emptyList() // Keeps track of the order in which running tasks appear. private var orderedRunningTaskIds = emptyList() var shownTasks: List = emptyList() private set val shownTaskIds: List get() = shownTasks.flatMap { shownTask -> shownTask.tasks }.map { it.key.id } /** * The task-state of an app, i.e. whether the app has a task and what state that task is in. * * @property taskId The ID of the task if one exists (i.e. if the state is RUNNING or * MINIMIZED), null otherwise (NOT_RUNNING). */ data class TaskState(val runningAppState: RunningAppState, val taskId: Int? = null) /** * Returns the state of the most active Desktop task represented by the given [ItemInfo]. * * If there are several tasks represented by the same [ItemInfo] we return the most active one, * i.e. we return [DesktopAppState.RUNNING] over [DesktopAppState.MINIMIZED], and * [DesktopAppState.MINIMIZED] over [DesktopAppState.NOT_RUNNING]. */ fun getDesktopItemState(itemInfo: ItemInfo?): TaskState { val packageName = itemInfo?.getTargetPackage() ?: return TaskState(RunningAppState.NOT_RUNNING) return getDesktopTaskState(packageName, itemInfo.user.identifier) } private fun getDesktopTaskState(packageName: String, userId: Int): TaskState { if (desktopTasks.isEmpty()) { return TaskState(RunningAppState.NOT_RUNNING) } val appTasks = desktopTasks.filter { task -> packageName == task.key.packageName && task.key.userId == userId } val runningTask = appTasks.find { getRunningAppState(it.key.id) == RunningAppState.RUNNING } if (runningTask != null) { return TaskState(RunningAppState.RUNNING, runningTask.key.id) } val minimizedTask = appTasks.find { getRunningAppState(it.key.id) == RunningAppState.MINIMIZED } if (minimizedTask != null) { return TaskState(RunningAppState.MINIMIZED, taskId = minimizedTask.key.id) } return TaskState(RunningAppState.NOT_RUNNING) } /** Get the [RunningAppState] for the given task. */ fun getRunningAppState(taskId: Int): RunningAppState { return when (taskId) { in minimizedTaskIds -> RunningAppState.MINIMIZED in runningTaskIds -> RunningAppState.RUNNING else -> RunningAppState.NOT_RUNNING } } /** Returns the single task (i.e., fullscreen) represented by the given [itemInfo]. */ fun getSingleTask(itemInfo: ItemInfo?): SingleTask? { val packageName = itemInfo?.targetPackage ?: return null return allRecentTasks.find { task -> task is SingleTask && packageName == task.task.key.packageName && task.task.key.userId == itemInfo.user.identifier } as? SingleTask } @VisibleForTesting val runningTaskIds: Set /** * Returns the task IDs of apps that should be indicated as "running" to the user. * Specifically, we return all the open tasks if we are in Desktop mode, else emptySet(). */ get() { if ( !canShowRunningApps || !controllers.taskbarDesktopModeController.shouldShowDesktopTasksInTaskbar() ) { return emptySet() } return desktopTasks.map { it.key.id }.toSet() } @VisibleForTesting val minimizedTaskIds: Set /** * Returns the task IDs for the tasks that should be indicated as "minimized" to the user. */ get() { if ( !canShowRunningApps || !controllers.taskbarDesktopModeController.shouldShowDesktopTasksInTaskbar() ) { return emptySet() } val multipleDesktopsEnabled = DesktopModeStatus.enableMultipleDesktops(context) // When multi-desks is enabled, the indicator only indicates whether the window is // minimized or not. This means an opened window inside an inactive desk will still // have long app indicator inside the taskbar. return desktopTasks .filter { task -> val shouldKeep = if (multipleDesktopsEnabled) task.isMinimized else !task.isVisible shouldKeep } .map { task -> task.key.id } .toSet() } private val recentTasksChangedListener = RecentsModel.RecentTasksChangedListener { reloadRecentTasksIfNeeded() } private val iconLoadRequests: MutableSet> = HashSet() // TODO(b/343291428): add TaskVisualsChangListener as well (for calendar/clock?) // Used to keep track of the last requested task list ID, so that we do not request to load the // tasks again if we have already requested it and the task list has not changed private var taskListChangeId = -1 // Whether we're currently loading recents tasks private var loadingRecentsTasks = false // Whether we need to reload recents tasks when the current loading operation is finished private var needsRecentsTasksReload = false // Whether we've loaded recents tasks at least once private var recentTasksLoaded = false private var iconShapeDataCloseable: SafeCloseable? = null private var themeChangeListener: ThemeChangeListener? = null fun init(taskbarControllers: TaskbarControllers, previousShownTasks: List) { controllers = taskbarControllers if (!controllers.taskbarActivityContext.deviceProfile.isTaskbarPresent) return if (previousShownTasks.isNotEmpty()) { shownTasks = previousShownTasks fetchIcons() } orderedRunningTaskIds = controllers.sharedState?.recentOrderedRunningTaskIds?.filterNotNull() ?: emptyList() if (canShowRunningApps || canShowRecentApps) { recentsModel.registerRecentTasksChangedListener(recentTasksChangedListener) controllers.runAfterInit { reloadRecentTasksIfNeeded() } if (enableTaskbarRecentsThemedIcons()) { iconShapeDataCloseable = themeManager.iconShapeData.forEach(MAIN_EXECUTOR) { fetchIcons() } themeChangeListener = ThemeChangeListener { fetchIcons() }.also { themeManager.addChangeListener(it) } } } } fun onDestroy() { controllers.sharedState?.recentTasksBeforeTaskbarRecreate?.clear() if (shownTasks.isNotEmpty()) { controllers.sharedState?.recentTasksBeforeTaskbarRecreate?.addAll(shownTasks) } controllers.sharedState?.recentOrderedRunningTaskIds?.clear() if (orderedRunningTaskIds.isNotEmpty()) { controllers.sharedState?.recentOrderedRunningTaskIds?.addAll(orderedRunningTaskIds) } recentsModel.unregisterRecentTasksChangedListener(recentTasksChangedListener) cancelIconLoadRequests() iconShapeDataCloseable?.close() themeChangeListener?.let { themeManager.removeChangeListener(it) } } private fun cancelIconLoadRequests() { for (it in iconLoadRequests) it.cancel() iconLoadRequests.clear() } /** Called to update hotseatItems, in order to de-dupe them from Recent/Running tasks later. */ fun updateHotseatItemInfos(hotseatItems: Array): Array { // Ignore predicted apps - we show running or recent apps instead. val showDesktopTasks = controllers.taskbarDesktopModeController.shouldShowDesktopTasksInTaskbar() val removePredictions = (showDesktopTasks && canShowRunningApps) || (!showDesktopTasks && canShowRecentApps) if (!removePredictions) { shownHotseatItems = hotseatItems.filterNotNull() onRecentsOrHotseatChanged() return hotseatItems } shownHotseatItems = hotseatItems .filterNotNull() .filter { itemInfo -> !itemInfo.isPredictedItem } .toMutableList() if (showDesktopTasks && canShowRunningApps) { shownHotseatItems = updateHotseatItemsFromRunningTasks(shownHotseatItems) } if (recentTasksLoaded) { onRecentsOrHotseatChanged() } return shownHotseatItems.toTypedArray() } fun getDesktopTaskWithId(id: Int): Task? { return desktopTasks.find { it.key.id == id } } private fun getOrderedAndWrappedDesktopTasks(): List { // We wrap each task in the Desktop as a `SingleTask`. val orderFromId = orderedRunningTaskIds.withIndex().associate { (index, id) -> id to index } val sortedTasks = desktopTasks.sortedWith(compareBy(nullsLast()) { orderFromId[it.key.id] }) return sortedTasks.map { SingleTask(it) } } /** * Fetches the latest tasks from [RecentsModel] if the list is stale, throttling to prevent * redundant calls. Triggers a UI commit if significant changes are detected. */ private fun reloadRecentTasksIfNeeded() { if (recentsModel.isTaskListValid(taskListChangeId)) return if (enableRecentTasksThrottle && loadingRecentsTasks) { Log.v(TAG, "reloadRecentTasksIfNeeded: tried to reload while loading recents tasks") needsRecentsTasksReload = true return } Log.v(TAG, "reloadRecentTasksIfNeeded: load recents tasks") // Only indicate that recents tasks are loading if the enableRecentTasksThrottle flag is on loadingRecentsTasks = enableRecentTasksThrottle taskListChangeId = recentsModel.getTasks(RecentsFilterState.EMPTY_FILTER) { tasks -> allRecentTasks = tasks val oldRunningIds = runningTaskIds val oldMinimizedIds = minimizedTaskIds loadingRecentsTasks = false recentTasksLoaded = true if ( shouldCommitUiUpdate(oldRunningIds, oldMinimizedIds) && !controllers.taskbarDesktopModeController.isLauncherAnimationRunning ) { controllers.taskbarViewController.commitRunningAppsToUI() } if (needsRecentsTasksReload) { Log.v(TAG, "reloadRecentTasksIfNeeded: reload recents tasks") needsRecentsTasksReload = false reloadRecentTasksIfNeeded() } } } /** Updates the internal task lists and returns whether the active desktop has changed. */ private fun updateDesktopTasksAndCheckActiveDeskChange(): Boolean { // Sort the full list of tasks by recency first. val sortedDesktopTasks = allRecentTasks.filterIsInstance().toSortedTaskList() if (DesktopModeStatus.enableMultipleDesktops(context)) { val activeDeskId = controllers.taskbarDesktopModeController.getActiveDeskId val hasActiveDeskChanged = activeDeskId != previousDeskId && previousDeskId != DesktopVisibilityController.INACTIVE_DESK_ID previousDeskId = activeDeskId if (hasActiveDeskChanged) { // If the desk changed, re-order the already sorted list to prioritize the active // desk. val taskToDeskIdMap = allRecentTasks .filterIsInstance() .flatMap { desktopTask -> desktopTask.tasks.map { task -> task.key.id to desktopTask.deskId } } .toMap() val (activeTasks, inactiveTasks) = sortedDesktopTasks.partition { task -> taskToDeskIdMap[task.key.id] == activeDeskId } desktopTasks = activeTasks + inactiveTasks return true } } desktopTasks = sortedDesktopTasks return false } /** * Determines if a UI commit to update the hotseat is warranted based on various state changes. */ private fun shouldCommitUiUpdate(oldRunningIds: Set, oldMinimizedIds: Set): Boolean { val activeDeskChanged = updateDesktopTasksAndCheckActiveDeskChange() val recentsOrHotseatChanged = onRecentsOrHotseatChanged() val runningTasksChanged = oldRunningIds != runningTaskIds val minimizedTasksChanged = oldMinimizedIds != minimizedTaskIds val multiInstanceFocusChanged = hasMultiInstanceFocusChanged() return recentsOrHotseatChanged || runningTasksChanged || minimizedTasksChanged || activeDeskChanged || multiInstanceFocusChanged } /** * Checks if the focused task has changed to a different instance of a multi-instance app that * is currently pinned to the hotseat. */ private fun hasMultiInstanceFocusChanged(): Boolean { val newMostRecentTask = desktopTasks.firstOrNull() ?: return false val newMostRecentTaskId = newMostRecentTask.key.id val focusHasChanged = newMostRecentTaskId != mostRecentTaskId mostRecentTaskId = newMostRecentTaskId if (!focusHasChanged) { return false } val packageName = newMostRecentTask.key.packageName val userId = newMostRecentTask.key.userId val instanceCount = desktopTasks.count { it.key.packageName == packageName && it.key.userId == userId } return instanceCount > 1 && shownHotseatItems.filterIsInstance().any { it.targetPackage == packageName && it.user.identifier == userId } } /** Flattens, filters, and sorts [DesktopTask]s by last active time (descending) */ private fun List.toSortedTaskList(): List { return this.flatMap { it.tasks } .filterNot { it.key.isTopActivityTransparent && it.key.isActivityStackTransparent && it.key.windowingMode == WINDOWING_MODE_FULLSCREEN } .sortedByDescending { it.key.lastActiveTime } } /** * Updates [shownTasks] when Recents or Hotseat changes. * * @return Whether [shownTasks] changed. */ private fun onRecentsOrHotseatChanged(): Boolean { val oldShownTasks = shownTasks orderedRunningTaskIds = updateOrderedRunningTaskIds() shownTasks = if (controllers.taskbarDesktopModeController.shouldShowDesktopTasksInTaskbar()) { computeShownRunningTasks() } else { computeShownRecentTasks() } if (oldShownTasks == shownTasks) return false fetchIcons() return true } private fun fetchIcons() { if (enableRecentsInTaskbar()) { cancelIconLoadRequests() // Cancel any previous requests. } for (groupTask in shownTasks) { for ((i, task) in groupTask.tasks.withIndex()) { val cancellableTask = if (enableTaskbarRecentsThemedIcons()) { recentsModel.iconCache.getBitmapInfoInBackground(task) { bi, d, t -> groupTask.bitmapInfos[i] = bi task.titleDescription = d task.title = t controllers.taskbarViewController.onTaskUpdated(task, groupTask) } } else { recentsModel.iconCache.getIconInBackground(task) { ic, d, t -> task.icon = ic task.titleDescription = d task.title = t controllers.taskbarViewController.onTaskUpdated(task, groupTask) } } if (cancellableTask != null) { iconLoadRequests.add(cancellableTask) } } } } private fun updateOrderedRunningTaskIds(): MutableList { val desktopTasksAsList = getOrderedAndWrappedDesktopTasks().map { it.task } val desktopTaskIds = desktopTasksAsList.map { it.key.id } var newOrder = orderedRunningTaskIds .filter { it in desktopTaskIds } // Only keep the tasks that are still running .toMutableList() // Add new tasks not already listed newOrder.addAll(desktopTaskIds.filter { it !in newOrder }) return newOrder } /** * Computes the list of running tasks to be shown in the recent apps section of the taskbar in * desktop mode, taking into account deduplication against hotseat items and existing tasks. */ private fun computeShownRunningTasks(): List { if (!canShowRunningApps) { return emptyList() } val desktopTasks = desktopTasks.map { SingleTask(it) } val newShownTasks = if (Flags.enableMultiInstanceMenuTaskbar()) { val deduplicatedDesktopTasks = desktopTasks.distinctBy { Pair(it.task.key.packageName, it.task.key.userId) } val activityContext = controllers.taskbarActivityContext val deduplicatedTaskIds = deduplicatedDesktopTasks.map { it.task.key.id }.toSet() shownTasks .filter { it is SingleTask && it.task.key.id in deduplicatedTaskIds && (!canPinAppWithContextMenu(activityContext) || shownHotseatItems.none { hotseatItem -> it.containsPackage( hotseatItem.targetPackage, hotseatItem.user.identifier, ) }) } .toMutableList() .apply { addAll( deduplicatedDesktopTasks.filter { currentTask -> val currentTaskKey = currentTask.task.key currentTaskKey.id !in shownTaskIds && shownHotseatItems.none { hotseatItem -> currentTask.containsPackage( hotseatItem.targetPackage, hotseatItem.user.identifier, ) } } ) } } else { val desktopTaskIds = desktopTasks.map { it.task.key.id } val shownHotseatItemTaskIds = shownHotseatItems.mapNotNull { it as? TaskItemInfo }.map { it.taskId } shownTasks .filter { it is SingleTask && it.task.key.id in desktopTaskIds } .toMutableList() .apply { addAll( desktopTasks.filter { desktopTask -> desktopTask.task.key.id !in shownTaskIds } ) removeAll { it is SingleTask && it.task.key.id in shownHotseatItemTaskIds } } } return newShownTasks } private fun computeShownRecentTasks(): List { if (!canShowRecentApps || allRecentTasks.isEmpty()) { return emptyList() } // Remove the current task. val allRecentTasks = allRecentTasks.subList(0, allRecentTasks.size - 1) var shownTasks = dedupeHotseatTasks(allRecentTasks, shownHotseatItems) if (shownTasks.size > MAX_RECENT_TASKS) { // Remove any tasks older than MAX_RECENT_TASKS. shownTasks = shownTasks.subList(shownTasks.size - MAX_RECENT_TASKS, shownTasks.size) } return shownTasks } private fun dedupeHotseatTasks( groupTasks: List, shownHotseatItems: List, ): List { // TODO: b/393476333 - Check the behavior of the Taskbar recents section when empty desks // become supported. return if (Flags.enableMultiInstanceMenuTaskbar()) { groupTasks.filter { groupTask -> // Keep tasks that are group tasks or unique package name/user combinations when (groupTask) { is SingleTask -> shownHotseatItems.none { groupTask.containsPackage(it.targetPackage, it.user.identifier) } is SplitTask -> shownHotseatItems.filterIsInstance().none { val firstPackage = it.getFirstApp().targetPackage val secondPackage = it.getSecondApp().targetPackage val userId = it.user.identifier // Dedupe even if the app order is swapped. groupTask.containsPackage(firstPackage, userId) && groupTask.containsPackage(secondPackage, userId) } else -> true } } } else { val hotseatPackages = shownHotseatItems.map { it.targetPackage } groupTasks.filter { groupTask -> when (groupTask) { is SingleTask -> hotseatPackages.none { groupTask.containsPackage(it) } else -> true } } } } /** * Returns the hotseat items updated so that any item that points to a package+user with a * running task also references that task. */ private fun updateHotseatItemsFromRunningTasks( shownHotseatItems: List ): List = shownHotseatItems.map { itemInfo -> if (!Flags.enableMultiInstanceMenuTaskbar() && itemInfo is TaskItemInfo) { itemInfo } else { val foundTask = desktopTasks.find { task -> task.key.packageName == itemInfo.targetPackage && task.key.userId == itemInfo.user.identifier } ?: return@map itemInfo TaskItemInfo(foundTask.key.id, itemInfo as WorkspaceItemInfo) } } override fun dumpLogs(prefix: String, pw: PrintWriter) { pw.println("$prefix TaskbarRecentAppsController:") pw.println("$prefix\tcanShowRunningApps=$canShowRunningApps") pw.println("$prefix\tcanShowRecentApps=$canShowRecentApps") pw.println("$prefix\tshownHotseatItems=${shownHotseatItems.map{item->item.targetPackage}}") pw.println("$prefix\tallRecentTasks=${allRecentTasks.map { it.packageNames }}") pw.println("$prefix\tdesktopTask=${desktopTasks.map { it.key.packageName }}") pw.println("$prefix\tshownTasks=${shownTasks.map { it.packageNames }}") pw.println("$prefix\trunningTaskIds=$runningTaskIds") pw.println("$prefix\tminimizedTaskIds=$minimizedTaskIds") } private val GroupTask.packageNames: List get() = tasks.map { task -> task.key.packageName } private companion object { private val TAG = "TaskbarRecentAppsController" const val MAX_RECENT_TASKS = 2 } }