1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.quickstep.views 17 18 import android.app.ActivityTaskManager.INVALID_TASK_ID 19 import android.content.Context 20 import android.graphics.PointF 21 import android.util.AttributeSet 22 import android.util.Log 23 import android.view.View 24 import com.android.internal.jank.Cuj 25 import com.android.launcher3.Flags.enableOverviewIconMenu 26 import com.android.launcher3.R 27 import com.android.launcher3.Utilities 28 import com.android.launcher3.config.FeatureFlags 29 import com.android.launcher3.util.RunnableList 30 import com.android.launcher3.util.SplitConfigurationOptions 31 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_BOTTOM_OR_RIGHT 32 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_TOP_OR_LEFT 33 import com.android.launcher3.util.SplitConfigurationOptions.STAGE_POSITION_UNDEFINED 34 import com.android.quickstep.TaskOverlayFactory 35 import com.android.quickstep.util.RecentsOrientedState 36 import com.android.quickstep.util.SplitScreenUtils.Companion.convertLauncherSplitBoundsToShell 37 import com.android.quickstep.util.SplitSelectStateController 38 import com.android.systemui.shared.recents.model.Task 39 import com.android.systemui.shared.recents.utilities.PreviewPositionHelper 40 import com.android.systemui.shared.system.InteractionJankMonitorWrapper 41 import com.android.wm.shell.common.split.SplitScreenConstants.PersistentSnapPosition 42 43 /** 44 * TaskView that contains and shows thumbnails for not one, BUT TWO(!!) tasks 45 * 46 * That's right. If you call within the next 5 minutes we'll go ahead and double your order and send 47 * you !! TWO !! Tasks along with their TaskThumbnailViews complimentary. On. The. House. And not 48 * only that, we'll even clean up your thumbnail request if you don't like it. All the benefits of 49 * one TaskView, except DOUBLED! 50 * 51 * (Icon loading sold separately, fees may apply. Shipping & Handling for Overlays not included). 52 */ 53 class GroupedTaskView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : 54 TaskView(context, attrs) { 55 // TODO(b/336612373): Support new TTV for GroupedTaskView 56 var splitBoundsConfig: SplitConfigurationOptions.SplitBounds? = null 57 private set 58 59 @get:PersistentSnapPosition 60 val snapPosition: Int 61 /** Returns the [PersistentSnapPosition] of this pair of tasks. */ 62 get() = splitBoundsConfig?.snapPosition ?: STAGE_POSITION_UNDEFINED 63 onMeasurenull64 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 65 super.onMeasure(widthMeasureSpec, heightMeasureSpec) 66 val widthSize = MeasureSpec.getSize(widthMeasureSpec) 67 val heightSize = MeasureSpec.getSize(heightMeasureSpec) 68 setMeasuredDimension(widthSize, heightSize) 69 val splitBoundsConfig = splitBoundsConfig ?: return 70 val initSplitTaskId = getThisTaskCurrentlyInSplitSelection() 71 if (initSplitTaskId == INVALID_TASK_ID) { 72 pagedOrientationHandler.measureGroupedTaskViewThumbnailBounds( 73 taskContainers[0].thumbnailViewDeprecated, 74 taskContainers[1].thumbnailViewDeprecated, 75 widthSize, 76 heightSize, 77 splitBoundsConfig, 78 container.deviceProfile, 79 layoutDirection == LAYOUT_DIRECTION_RTL 80 ) 81 // Should we be having a separate translation step apart from the measuring above? 82 // The following only applies to large screen for now, but for future reference 83 // we'd want to abstract this out in PagedViewHandlers to get the primary/secondary 84 // translation directions 85 taskContainers[0] 86 .thumbnailViewDeprecated 87 .applySplitSelectTranslateX(taskContainers[0].thumbnailViewDeprecated.translationX) 88 taskContainers[0] 89 .thumbnailViewDeprecated 90 .applySplitSelectTranslateY(taskContainers[0].thumbnailViewDeprecated.translationY) 91 taskContainers[1] 92 .thumbnailViewDeprecated 93 .applySplitSelectTranslateX(taskContainers[1].thumbnailViewDeprecated.translationX) 94 taskContainers[1] 95 .thumbnailViewDeprecated 96 .applySplitSelectTranslateY(taskContainers[1].thumbnailViewDeprecated.translationY) 97 } else { 98 // Currently being split with this taskView, let the non-split selected thumbnail 99 // take up full thumbnail area 100 taskContainers 101 .firstOrNull { it.task.key.id != initSplitTaskId } 102 ?.thumbnailViewDeprecated 103 ?.measure( 104 widthMeasureSpec, 105 MeasureSpec.makeMeasureSpec( 106 heightSize - container.deviceProfile.overviewTaskThumbnailTopMarginPx, 107 MeasureSpec.EXACTLY 108 ) 109 ) 110 } 111 if (!enableOverviewIconMenu()) { 112 updateIconPlacement() 113 } 114 } 115 onRecyclenull116 override fun onRecycle() { 117 super.onRecycle() 118 splitBoundsConfig = null 119 } 120 bindnull121 fun bind( 122 primaryTask: Task, 123 secondaryTask: Task, 124 orientedState: RecentsOrientedState, 125 taskOverlayFactory: TaskOverlayFactory, 126 splitBoundsConfig: SplitConfigurationOptions.SplitBounds?, 127 ) { 128 cancelPendingLoadTasks() 129 taskContainers = 130 listOf( 131 createTaskContainer( 132 primaryTask, 133 R.id.snapshot, 134 R.id.icon, 135 R.id.show_windows, 136 STAGE_POSITION_TOP_OR_LEFT, 137 taskOverlayFactory 138 ), 139 createTaskContainer( 140 secondaryTask, 141 R.id.bottomright_snapshot, 142 R.id.bottomRight_icon, 143 R.id.show_windows_right, 144 STAGE_POSITION_BOTTOM_OR_RIGHT, 145 taskOverlayFactory 146 ) 147 ) 148 this.splitBoundsConfig = 149 splitBoundsConfig?.also { 150 taskContainers[0] 151 .thumbnailViewDeprecated 152 .previewPositionHelper 153 .setSplitBounds( 154 convertLauncherSplitBoundsToShell(it), 155 PreviewPositionHelper.STAGE_POSITION_TOP_OR_LEFT 156 ) 157 taskContainers[1] 158 .thumbnailViewDeprecated 159 .previewPositionHelper 160 .setSplitBounds( 161 convertLauncherSplitBoundsToShell(it), 162 PreviewPositionHelper.STAGE_POSITION_BOTTOM_OR_RIGHT 163 ) 164 } 165 taskContainers.forEach { it.digitalWellBeingToast?.setSplitBounds(splitBoundsConfig) } 166 setOrientationState(orientedState) 167 } 168 setOrientationStatenull169 override fun setOrientationState(orientationState: RecentsOrientedState) { 170 if (enableOverviewIconMenu()) { 171 splitBoundsConfig?.let { 172 val groupedTaskViewSizes = 173 orientationState.orientationHandler.getGroupedTaskViewSizes( 174 container.deviceProfile, 175 it, 176 layoutParams.width, 177 layoutParams.height 178 ) 179 val iconViewMarginStart = 180 resources.getDimensionPixelSize( 181 R.dimen.task_thumbnail_icon_menu_expanded_top_start_margin 182 ) 183 val iconViewBackgroundMarginStart = 184 resources.getDimensionPixelSize( 185 R.dimen.task_thumbnail_icon_menu_background_margin_top_start 186 ) 187 val iconMargins = (iconViewMarginStart + iconViewBackgroundMarginStart) * 2 188 // setMaxWidth() needs to be called before mIconView.setIconOrientation which is 189 // called in the super below. 190 (taskContainers[0].iconView as IconAppChipView).setMaxWidth( 191 groupedTaskViewSizes.first.x - iconMargins 192 ) 193 (taskContainers[1].iconView as IconAppChipView).setMaxWidth( 194 groupedTaskViewSizes.second.x - iconMargins 195 ) 196 } 197 } 198 super.setOrientationState(orientationState) 199 updateIconPlacement() 200 } 201 updateIconPlacementnull202 private fun updateIconPlacement() { 203 val splitBoundsConfig = splitBoundsConfig ?: return 204 val taskIconHeight = container.deviceProfile.overviewTaskIconSizePx 205 val isRtl = layoutDirection == LAYOUT_DIRECTION_RTL 206 if (enableOverviewIconMenu()) { 207 val groupedTaskViewSizes = 208 pagedOrientationHandler.getGroupedTaskViewSizes( 209 container.deviceProfile, 210 splitBoundsConfig, 211 layoutParams.width, 212 layoutParams.height 213 ) 214 pagedOrientationHandler.setSplitIconParams( 215 taskContainers[0].iconView.asView(), 216 taskContainers[1].iconView.asView(), 217 taskIconHeight, 218 groupedTaskViewSizes.first.x, 219 groupedTaskViewSizes.first.y, 220 layoutParams.height, 221 layoutParams.width, 222 isRtl, 223 container.deviceProfile, 224 splitBoundsConfig 225 ) 226 } else { 227 pagedOrientationHandler.setSplitIconParams( 228 taskContainers[0].iconView.asView(), 229 taskContainers[1].iconView.asView(), 230 taskIconHeight, 231 taskContainers[0].thumbnailViewDeprecated.measuredWidth, 232 taskContainers[0].thumbnailViewDeprecated.measuredHeight, 233 measuredHeight, 234 measuredWidth, 235 isRtl, 236 container.deviceProfile, 237 splitBoundsConfig 238 ) 239 } 240 } 241 updateSplitBoundsConfignull242 fun updateSplitBoundsConfig(splitBounds: SplitConfigurationOptions.SplitBounds?) { 243 splitBoundsConfig = splitBounds 244 taskContainers.forEach { 245 it.digitalWellBeingToast?.setSplitBounds(splitBoundsConfig) 246 it.digitalWellBeingToast?.initialize(it.task) 247 } 248 invalidate() 249 } 250 launchTaskAnimatednull251 override fun launchTaskAnimated(): RunnableList? { 252 if (taskContainers.isEmpty()) { 253 Log.d(TAG, "launchTaskAnimated - task is not bound") 254 return null 255 } 256 val recentsView = recentsView ?: return null 257 val endCallback = RunnableList() 258 // Callbacks run from remote animation when recents animation not currently running 259 InteractionJankMonitorWrapper.begin( 260 this, 261 Cuj.CUJ_SPLIT_SCREEN_ENTER, 262 "Enter form GroupedTaskView" 263 ) 264 launchTaskInternal(isQuickSwitch = false, launchingExistingTaskView = true) { 265 endCallback.executeAllAndDestroy() 266 InteractionJankMonitorWrapper.end(Cuj.CUJ_SPLIT_SCREEN_ENTER) 267 } 268 269 // Callbacks get run from recentsView for case when recents animation already running 270 recentsView.addSideTaskLaunchCallback(endCallback) 271 return endCallback 272 } 273 launchTasknull274 override fun launchTask(callback: (launched: Boolean) -> Unit, isQuickSwitch: Boolean) { 275 launchTaskInternal(isQuickSwitch, false, callback /*launchingExistingTaskview*/) 276 } 277 278 /** 279 * @param launchingExistingTaskView [SplitSelectStateController.launchExistingSplitPair] uses 280 * existence of GroupedTaskView as control flow of how to animate in the incoming task. If 281 * we're launching from overview (from overview thumbnails) then pass in `true`, otherwise 282 * pass in `false` for case like quickswitching from home to task 283 */ launchTaskInternalnull284 private fun launchTaskInternal( 285 isQuickSwitch: Boolean, 286 launchingExistingTaskView: Boolean, 287 callback: (launched: Boolean) -> Unit 288 ) { 289 recentsView?.let { 290 it.splitSelectController.launchExistingSplitPair( 291 if (launchingExistingTaskView) this else null, 292 taskContainers[0].task.key.id, 293 taskContainers[1].task.key.id, 294 STAGE_POSITION_TOP_OR_LEFT, 295 callback, 296 isQuickSwitch, 297 snapPosition 298 ) 299 Log.d(TAG, "launchTaskInternal - launchExistingSplitPair: ${taskIds.contentToString()}") 300 } 301 } 302 303 /** 304 * Returns taskId that split selection was initiated with, [INVALID_TASK_ID] if no tasks in this 305 * TaskView are part of split selection 306 */ getThisTaskCurrentlyInSplitSelectionnull307 private fun getThisTaskCurrentlyInSplitSelection(): Int { 308 val initialTaskId = recentsView?.splitSelectController?.initialTaskId 309 return if (initialTaskId != null && containsTaskId(initialTaskId)) initialTaskId 310 else INVALID_TASK_ID 311 } 312 getLastSelectedChildTaskIndexnull313 override fun getLastSelectedChildTaskIndex(): Int { 314 if (recentsView?.splitSelectController?.isDismissingFromSplitPair == true) { 315 // return the container index of the task that wasn't initially selected to split 316 // with because that is the only remaining app that can be selected. The coordinate 317 // checks below aren't reliable since both of those views may be gone/transformed 318 val initSplitTaskId = getThisTaskCurrentlyInSplitSelection() 319 if (initSplitTaskId != INVALID_TASK_ID) { 320 return if (initSplitTaskId == taskContainers[0].task.key.id) 1 else 0 321 } 322 } 323 324 // Check which of the two apps was selected 325 if ( 326 taskContainers[1].iconView.asView().containsPoint(lastTouchDownPosition) || 327 taskContainers[1].thumbnailViewDeprecated.containsPoint(lastTouchDownPosition) 328 ) { 329 return 1 330 } 331 return super.getLastSelectedChildTaskIndex() 332 } 333 containsPointnull334 private fun View.containsPoint(position: PointF): Boolean { 335 val localPos = floatArrayOf(position.x, position.y) 336 Utilities.mapCoordInSelfToDescendant(this, this@GroupedTaskView, localPos) 337 return Utilities.pointInView(this, localPos[0], localPos[1], 0f /* slop */) 338 } 339 setOverlayEnablednull340 override fun setOverlayEnabled(overlayEnabled: Boolean) { 341 if (FeatureFlags.enableAppPairs()) { 342 super.setOverlayEnabled(overlayEnabled) 343 } else { 344 // Intentional no-op to prevent setting smart actions overlay on thumbnails 345 } 346 } 347 348 companion object { 349 private const val TAG = "GroupedTaskView" 350 } 351 } 352