| /* |
| * Copyright (C) 2018 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 |
| |
| import android.animation.Animator |
| import android.animation.AnimatorListenerAdapter |
| import android.content.Intent |
| import android.graphics.PointF |
| import android.os.SystemClock |
| import android.os.Trace |
| import android.util.Log |
| import android.view.View |
| import androidx.annotation.BinderThread |
| import androidx.annotation.UiThread |
| import androidx.annotation.VisibleForTesting |
| import com.android.internal.jank.Cuj |
| import com.android.launcher3.Flags.enableLargeDesktopWindowingTile |
| import com.android.launcher3.Flags.enableOverviewCommandHelperTimeout |
| import com.android.launcher3.PagedView |
| import com.android.launcher3.logger.LauncherAtom |
| import com.android.launcher3.logging.StatsLogManager |
| import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_3_BUTTON |
| import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_KEYBOARD_QUICK_SWITCH |
| import com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_KEYBOARD_SHORTCUT |
| import com.android.launcher3.util.Executors |
| import com.android.launcher3.util.RunnableList |
| import com.android.launcher3.util.coroutines.DispatcherProvider |
| import com.android.launcher3.util.coroutines.ProductionDispatchers |
| import com.android.quickstep.OverviewCommandHelper.CommandInfo.CommandStatus |
| import com.android.quickstep.OverviewCommandHelper.CommandType.HIDE |
| import com.android.quickstep.OverviewCommandHelper.CommandType.HOME |
| import com.android.quickstep.OverviewCommandHelper.CommandType.KEYBOARD_INPUT |
| import com.android.quickstep.OverviewCommandHelper.CommandType.SHOW |
| import com.android.quickstep.OverviewCommandHelper.CommandType.TOGGLE |
| import com.android.quickstep.util.ActiveGestureLog |
| import com.android.quickstep.util.ActiveGestureProtoLogProxy |
| import com.android.quickstep.views.RecentsView |
| import com.android.quickstep.views.TaskView |
| import com.android.systemui.shared.recents.model.ThumbnailData |
| import com.android.systemui.shared.system.InteractionJankMonitorWrapper |
| import java.io.PrintWriter |
| import java.util.concurrent.ConcurrentLinkedDeque |
| import kotlin.coroutines.resume |
| import kotlinx.coroutines.CoroutineScope |
| import kotlinx.coroutines.SupervisorJob |
| import kotlinx.coroutines.ensureActive |
| import kotlinx.coroutines.launch |
| import kotlinx.coroutines.suspendCancellableCoroutine |
| import kotlinx.coroutines.withTimeout |
| |
| /** Helper class to handle various atomic commands for switching between Overview. */ |
| class OverviewCommandHelper |
| @JvmOverloads |
| constructor( |
| private val touchInteractionService: TouchInteractionService, |
| private val overviewComponentObserver: OverviewComponentObserver, |
| private val taskAnimationManager: TaskAnimationManager, |
| private val dispatcherProvider: DispatcherProvider = ProductionDispatchers, |
| ) { |
| private val coroutineScope = CoroutineScope(SupervisorJob() + dispatcherProvider.background) |
| |
| private val commandQueue = ConcurrentLinkedDeque<CommandInfo>() |
| |
| /** |
| * Index of the TaskView that should be focused when launching Overview. Persisted so that we do |
| * not lose the focus across multiple calls of [OverviewCommandHelper.executeCommand] for the |
| * same command |
| */ |
| private var keyboardTaskFocusIndex = -1 |
| |
| private val containerInterface: BaseContainerInterface<*, *> |
| get() = overviewComponentObserver.containerInterface |
| |
| private val visibleRecentsView: RecentsView<*, *>? |
| get() = containerInterface.getVisibleRecentsView<RecentsView<*, *>>() |
| |
| /** |
| * Adds a command to be executed next, after all pending tasks are completed. Max commands that |
| * can be queued is [.MAX_QUEUE_SIZE]. Requests after reaching that limit will be silently |
| * dropped. |
| */ |
| @BinderThread |
| fun addCommand(type: CommandType): CommandInfo? { |
| if (commandQueue.size >= MAX_QUEUE_SIZE) { |
| Log.d(TAG, "command not added: $type - queue is full ($commandQueue).") |
| return null |
| } |
| |
| val command = CommandInfo(type) |
| commandQueue.add(command) |
| Log.d(TAG, "command added: $command") |
| |
| if (commandQueue.size == 1) { |
| Log.d(TAG, "execute: $command - queue size: ${commandQueue.size}") |
| if (enableOverviewCommandHelperTimeout()) { |
| coroutineScope.launch(dispatcherProvider.main) { processNextCommand() } |
| } else { |
| Executors.MAIN_EXECUTOR.execute { processNextCommand() } |
| } |
| } else { |
| Log.d(TAG, "not executed: $command - queue size: ${commandQueue.size}") |
| } |
| |
| return command |
| } |
| |
| fun canStartHomeSafely(): Boolean = commandQueue.isEmpty() || commandQueue.first().type == HOME |
| |
| /** Clear pending or completed commands from the queue */ |
| fun clearPendingCommands() { |
| Log.d(TAG, "clearing pending commands: $commandQueue") |
| commandQueue.removeAll { it.status != CommandStatus.PROCESSING } |
| } |
| |
| /** |
| * Executes the next command from the queue. If the command finishes immediately (returns true), |
| * it continues to execute the next command, until the queue is empty of a command defer's its |
| * completion (returns false). |
| */ |
| @UiThread |
| private fun processNextCommand() { |
| val command: CommandInfo = |
| commandQueue.firstOrNull() |
| ?: run { |
| Log.d(TAG, "no pending commands to be executed.") |
| return |
| } |
| |
| command.status = CommandStatus.PROCESSING |
| Log.d(TAG, "executing command: $command") |
| |
| if (enableOverviewCommandHelperTimeout()) { |
| coroutineScope.launch(dispatcherProvider.main) { |
| withTimeout(QUEUE_WAIT_DURATION_IN_MS) { |
| executeCommandSuspended(command) |
| ensureActive() |
| onCommandFinished(command) |
| } |
| } |
| } else { |
| val result = executeCommand(command, onCallbackResult = { onCommandFinished(command) }) |
| Log.d(TAG, "command executed: $command with result: $result") |
| if (result) { |
| onCommandFinished(command) |
| } else { |
| Log.d(TAG, "waiting for command callback: $command") |
| } |
| } |
| } |
| |
| /** |
| * Executes the task and returns true if next task can be executed. If false, then the next task |
| * is deferred until [.scheduleNextTask] is called |
| */ |
| @VisibleForTesting |
| fun executeCommand(command: CommandInfo, onCallbackResult: () -> Unit): Boolean { |
| val recentsView = visibleRecentsView |
| Log.d(TAG, "executeCommand: $command - visibleRecentsView: $recentsView") |
| return if (recentsView != null) { |
| executeWhenRecentsIsVisible(command, recentsView, onCallbackResult) |
| } else { |
| executeWhenRecentsIsNotVisible(command, onCallbackResult) |
| } |
| } |
| |
| /** |
| * Executes the task and returns true if next task can be executed. If false, then the next task |
| * is deferred until [.scheduleNextTask] is called |
| */ |
| private suspend fun executeCommandSuspended(command: CommandInfo) = |
| suspendCancellableCoroutine { continuation -> |
| fun processResult(isCompleted: Boolean) { |
| Log.d(TAG, "command executed: $command with result: $isCompleted") |
| if (isCompleted) { |
| continuation.resume(Unit) |
| } else { |
| Log.d(TAG, "waiting for command callback: $command") |
| } |
| } |
| |
| val result = executeCommand(command, onCallbackResult = { processResult(true) }) |
| processResult(result) |
| |
| continuation.invokeOnCancellation { cancelCommand(command, it) } |
| } |
| |
| private fun executeWhenRecentsIsVisible( |
| command: CommandInfo, |
| recentsView: RecentsView<*, *>, |
| onCallbackResult: () -> Unit, |
| ): Boolean = |
| when (command.type) { |
| SHOW -> true // already visible |
| KEYBOARD_INPUT, |
| HIDE -> { |
| if (recentsView.isHandlingTouch) { |
| true |
| } else { |
| keyboardTaskFocusIndex = PagedView.INVALID_PAGE |
| val currentPage = recentsView.nextPage |
| val taskView = recentsView.getTaskViewAt(currentPage) |
| launchTask(recentsView, taskView, command, onCallbackResult) |
| } |
| } |
| TOGGLE -> { |
| launchTask( |
| recentsView, |
| getNextToggledTaskView(recentsView), |
| command, |
| onCallbackResult, |
| ) |
| } |
| HOME -> { |
| recentsView.startHome() |
| true |
| } |
| } |
| |
| private fun getNextToggledTaskView(recentsView: RecentsView<*, *>): TaskView? { |
| // When running task view is null we return last large taskView - typically focusView when |
| // grid only is not enabled else last desktop task view. |
| return if (recentsView.runningTaskView == null) { |
| recentsView.lastLargeTaskView ?: recentsView.getTaskViewAt(0) |
| } else { |
| if ( |
| enableLargeDesktopWindowingTile() && |
| recentsView.getTaskViewCount() == recentsView.largeTilesCount && |
| recentsView.runningTaskView === recentsView.lastLargeTaskView |
| ) { |
| // Enables the toggle when only large tiles are in recents view. |
| // We return previous because unlike small tiles, large tiles are always |
| // on the right hand side. |
| recentsView.previousTaskView ?: recentsView.runningTaskView |
| } else { |
| recentsView.nextTaskView ?: recentsView.runningTaskView |
| } |
| } |
| } |
| |
| private fun launchTask( |
| recents: RecentsView<*, *>, |
| taskView: TaskView?, |
| command: CommandInfo, |
| onCallbackResult: () -> Unit, |
| ): Boolean { |
| var callbackList: RunnableList? = null |
| if (taskView != null) { |
| taskView.isEndQuickSwitchCuj = true |
| callbackList = taskView.launchWithAnimation() |
| } |
| |
| if (callbackList != null) { |
| callbackList.add { |
| Log.d(TAG, "launching task callback: $command") |
| onCallbackResult() |
| } |
| Log.d(TAG, "launching task - waiting for callback: $command") |
| return false |
| } else { |
| recents.startHome() |
| return true |
| } |
| } |
| |
| private fun executeWhenRecentsIsNotVisible( |
| command: CommandInfo, |
| onCallbackResult: () -> Unit, |
| ): Boolean { |
| val recentsViewContainer = containerInterface.getCreatedContainer() |
| val recentsView: RecentsView<*, *>? = recentsViewContainer?.getOverviewPanel() |
| val deviceProfile = recentsViewContainer?.getDeviceProfile() |
| val uiController = containerInterface.getTaskbarController() |
| val allowQuickSwitch = |
| uiController != null && |
| deviceProfile != null && |
| (deviceProfile.isTablet || deviceProfile.isTwoPanels) |
| |
| when (command.type) { |
| HIDE -> { |
| if (!allowQuickSwitch) return true |
| keyboardTaskFocusIndex = uiController!!.launchFocusedTask() |
| if (keyboardTaskFocusIndex == -1) return true |
| } |
| KEYBOARD_INPUT -> |
| if (allowQuickSwitch) { |
| uiController!!.openQuickSwitchView() |
| return true |
| } else { |
| keyboardTaskFocusIndex = 0 |
| } |
| HOME -> { |
| ActiveGestureProtoLogProxy.logExecuteHomeCommand() |
| // Although IActivityTaskManager$Stub$Proxy.startActivity is a slow binder call, |
| // we should still call it on main thread because launcher is waiting for |
| // ActivityTaskManager to resume it. Also calling startActivity() on bg thread |
| // could potentially delay resuming launcher. See b/348668521 for more details. |
| touchInteractionService.startActivity(overviewComponentObserver.homeIntent) |
| return true |
| } |
| SHOW -> |
| // When Recents is not currently visible, the command's type is SHOW |
| // when overview is triggered via the keyboard overview button or Action+Tab |
| // keys (Not Alt+Tab which is KQS). The overview button on-screen in 3-button |
| // nav is TYPE_TOGGLE. |
| keyboardTaskFocusIndex = 0 |
| TOGGLE -> {} |
| } |
| |
| recentsView?.setKeyboardTaskFocusIndex(keyboardTaskFocusIndex) |
| // Handle recents view focus when launching from home |
| val animatorListener: Animator.AnimatorListener = |
| object : AnimatorListenerAdapter() { |
| override fun onAnimationStart(animation: Animator) { |
| Log.d(TAG, "switching to Overview state - onAnimationStart: $command") |
| super.onAnimationStart(animation) |
| updateRecentsViewFocus(command) |
| logShowOverviewFrom(command.type) |
| } |
| |
| override fun onAnimationEnd(animation: Animator) { |
| Log.d(TAG, "switching to Overview state - onAnimationEnd: $command") |
| super.onAnimationEnd(animation) |
| onRecentsViewFocusUpdated(command) |
| onCallbackResult() |
| } |
| } |
| if (containerInterface.switchToRecentsIfVisible(animatorListener)) { |
| Log.d(TAG, "switching to Overview state - waiting: $command") |
| // If successfully switched, wait until animation finishes |
| return false |
| } |
| |
| val activity = containerInterface.getCreatedContainer() |
| if (activity != null) { |
| InteractionJankMonitorWrapper.begin(activity.rootView, Cuj.CUJ_LAUNCHER_QUICK_SWITCH) |
| } |
| |
| val gestureState = |
| touchInteractionService.createGestureState( |
| GestureState.DEFAULT_STATE, |
| GestureState.TrackpadGestureType.NONE, |
| ) |
| gestureState.isHandlingAtomicEvent = true |
| val interactionHandler = |
| touchInteractionService.swipeUpHandlerFactory.newHandler( |
| gestureState, |
| command.createTime, |
| ) |
| interactionHandler.setGestureEndCallback { |
| onTransitionComplete(command, interactionHandler, onCallbackResult) |
| } |
| interactionHandler.initWhenReady("OverviewCommandHelper: command.type=${command.type}") |
| |
| val recentAnimListener: RecentsAnimationCallbacks.RecentsAnimationListener = |
| object : RecentsAnimationCallbacks.RecentsAnimationListener { |
| override fun onRecentsAnimationStart( |
| controller: RecentsAnimationController, |
| targets: RecentsAnimationTargets, |
| ) { |
| Log.d(TAG, "recents animation started: $command") |
| updateRecentsViewFocus(command) |
| logShowOverviewFrom(command.type) |
| containerInterface.runOnInitBackgroundStateUI { |
| Log.d(TAG, "recents animation started - onInitBackgroundStateUI: $command") |
| interactionHandler.onGestureEnded(0f, PointF()) |
| } |
| command.removeListener(this) |
| } |
| |
| override fun onRecentsAnimationCanceled( |
| thumbnailDatas: HashMap<Int, ThumbnailData> |
| ) { |
| Log.d(TAG, "recents animation canceled: $command") |
| interactionHandler.onGestureCancelled() |
| command.removeListener(this) |
| |
| containerInterface.getCreatedContainer() ?: return |
| recentsView?.onRecentsAnimationComplete() |
| } |
| } |
| |
| if (taskAnimationManager.isRecentsAnimationRunning) { |
| command.setAnimationCallbacks( |
| taskAnimationManager.continueRecentsAnimation(gestureState) |
| ) |
| command.addListener(interactionHandler) |
| taskAnimationManager.notifyRecentsAnimationState(interactionHandler) |
| interactionHandler.onGestureStarted(true /*isLikelyToStartNewTask*/) |
| |
| command.addListener(recentAnimListener) |
| taskAnimationManager.notifyRecentsAnimationState(recentAnimListener) |
| } else { |
| val intent = |
| Intent(interactionHandler.getLaunchIntent()) |
| .putExtra(ActiveGestureLog.INTENT_EXTRA_LOG_TRACE_ID, gestureState.gestureId) |
| command.setAnimationCallbacks( |
| taskAnimationManager.startRecentsAnimation(gestureState, intent, interactionHandler) |
| ) |
| interactionHandler.onGestureStarted(false /*isLikelyToStartNewTask*/) |
| command.addListener(recentAnimListener) |
| } |
| Trace.beginAsyncSection(TRANSITION_NAME, 0) |
| Log.d(TAG, "switching via recents animation - onGestureStarted: $command") |
| return false |
| } |
| |
| private fun onTransitionComplete( |
| command: CommandInfo, |
| handler: AbsSwipeUpHandler<*, *, *>, |
| onCommandResult: () -> Unit, |
| ) { |
| Log.d(TAG, "switching via recents animation - onTransitionComplete: $command") |
| command.removeListener(handler) |
| Trace.endAsyncSection(TRANSITION_NAME, 0) |
| onRecentsViewFocusUpdated(command) |
| onCommandResult() |
| } |
| |
| /** Called when the command finishes execution. */ |
| private fun onCommandFinished(command: CommandInfo) { |
| command.status = CommandStatus.COMPLETED |
| if (commandQueue.firstOrNull() !== command) { |
| Log.d( |
| TAG, |
| "next task not scheduled. First pending command type " + |
| "is ${commandQueue.firstOrNull()} - command type is: $command", |
| ) |
| return |
| } |
| |
| Log.d(TAG, "command executed successfully! $command") |
| commandQueue.remove(command) |
| processNextCommand() |
| } |
| |
| private fun cancelCommand(command: CommandInfo, throwable: Throwable?) { |
| command.status = CommandStatus.CANCELED |
| Log.e(TAG, "command cancelled: $command - $throwable") |
| commandQueue.remove(command) |
| processNextCommand() |
| } |
| |
| private fun updateRecentsViewFocus(command: CommandInfo) { |
| val recentsView: RecentsView<*, *> = visibleRecentsView ?: return |
| if (command.type != KEYBOARD_INPUT && command.type != HIDE && command.type != SHOW) { |
| return |
| } |
| |
| // When the overview is launched via alt tab (command type is TYPE_KEYBOARD_INPUT), |
| // the touch mode somehow is not change to false by the Android framework. |
| // The subsequent tab to go through tasks in overview can only be dispatched to |
| // focuses views, while focus can only be requested in |
| // {@link View#requestFocusNoSearch(int, Rect)} when touch mode is false. To note, |
| // here we launch overview with live tile. |
| recentsView.viewRootImpl.touchModeChanged(false) |
| // Ensure that recents view has focus so that it receives the followup key inputs |
| // Stops requesting focused after first view gets focused. |
| recentsView.getTaskViewAt(keyboardTaskFocusIndex).requestFocus() || |
| recentsView.nextTaskView.requestFocus() || |
| recentsView.getTaskViewAt(0).requestFocus() || |
| recentsView.requestFocus() |
| } |
| |
| private fun onRecentsViewFocusUpdated(command: CommandInfo) { |
| val recentsView: RecentsView<*, *> = visibleRecentsView ?: return |
| if (command.type != HIDE || keyboardTaskFocusIndex == PagedView.INVALID_PAGE) { |
| return |
| } |
| recentsView.setKeyboardTaskFocusIndex(PagedView.INVALID_PAGE) |
| recentsView.currentPage = keyboardTaskFocusIndex |
| keyboardTaskFocusIndex = PagedView.INVALID_PAGE |
| } |
| |
| private fun View?.requestFocus(): Boolean { |
| if (this == null) return false |
| post { |
| requestFocus() |
| requestAccessibilityFocus() |
| } |
| return true |
| } |
| |
| private fun logShowOverviewFrom(commandType: CommandType) { |
| val container = containerInterface.getCreatedContainer() ?: return |
| val event = |
| when (commandType) { |
| SHOW -> LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_KEYBOARD_SHORTCUT |
| HIDE -> LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_KEYBOARD_QUICK_SWITCH |
| TOGGLE -> LAUNCHER_OVERVIEW_SHOW_OVERVIEW_FROM_3_BUTTON |
| else -> return |
| } |
| StatsLogManager.newInstance(container.asContext()) |
| .logger() |
| .withContainerInfo( |
| LauncherAtom.ContainerInfo.newBuilder() |
| .setTaskSwitcherContainer( |
| LauncherAtom.TaskSwitcherContainer.getDefaultInstance() |
| ) |
| .build() |
| ) |
| .log(event) |
| } |
| |
| fun dump(pw: PrintWriter) { |
| pw.println("OverviewCommandHelper:") |
| pw.println(" pendingCommands=${commandQueue.size}") |
| if (commandQueue.isNotEmpty()) { |
| pw.println(" pendingCommandType=${commandQueue.first().type}") |
| } |
| pw.println(" keyboardTaskFocusIndex=$keyboardTaskFocusIndex") |
| } |
| |
| @VisibleForTesting |
| data class CommandInfo( |
| val type: CommandType, |
| var status: CommandStatus = CommandStatus.IDLE, |
| val createTime: Long = SystemClock.elapsedRealtime(), |
| private var animationCallbacks: RecentsAnimationCallbacks? = null, |
| ) { |
| fun setAnimationCallbacks(recentsAnimationCallbacks: RecentsAnimationCallbacks) { |
| this.animationCallbacks = recentsAnimationCallbacks |
| } |
| |
| fun addListener(listener: RecentsAnimationCallbacks.RecentsAnimationListener) { |
| animationCallbacks?.addListener(listener) |
| } |
| |
| fun removeListener(listener: RecentsAnimationCallbacks.RecentsAnimationListener?) { |
| animationCallbacks?.removeListener(listener) |
| } |
| |
| enum class CommandStatus { |
| IDLE, |
| PROCESSING, |
| COMPLETED, |
| CANCELED, |
| } |
| } |
| |
| enum class CommandType { |
| SHOW, |
| KEYBOARD_INPUT, |
| HIDE, |
| TOGGLE, // Navigate to Overview |
| HOME, // Navigate to Home |
| } |
| |
| companion object { |
| private const val TAG = "OverviewCommandHelper" |
| private const val TRANSITION_NAME = "Transition:toOverview" |
| |
| /** |
| * Use case for needing a queue is double tapping recents button in 3 button nav. Size of 2 |
| * should be enough. We'll toss in one more because we're kind hearted. |
| */ |
| private const val MAX_QUEUE_SIZE = 3 |
| private const val QUEUE_WAIT_DURATION_IN_MS = 5000L |
| } |
| } |