1 /* 2 * Copyright (C) 2023 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.wm.shell.common.pip 17 18 import android.app.ActivityTaskManager 19 import android.app.AppGlobals 20 import android.app.RemoteAction 21 import android.app.WindowConfiguration 22 import android.content.ComponentName 23 import android.content.Context 24 import android.content.pm.PackageManager 25 import android.graphics.PointF 26 import android.graphics.Rect 27 import android.os.RemoteException 28 import android.util.DisplayMetrics 29 import android.util.Log 30 import android.util.Pair 31 import android.util.TypedValue 32 import android.window.TaskSnapshot 33 import android.window.TransitionInfo 34 import com.android.internal.protolog.ProtoLog 35 import com.android.wm.shell.Flags 36 import com.android.wm.shell.protolog.ShellProtoLogGroup 37 import kotlin.math.abs 38 import kotlin.math.ceil 39 import kotlin.math.floor 40 import kotlin.math.roundToInt 41 42 /** A class that includes convenience methods. */ 43 object PipUtils { 44 private const val TAG = "PipUtils" 45 46 // Minimum difference between two floats (e.g. aspect ratios) to consider them not equal. 47 // TODO b/377530560: Restore epsilon once a long term fix is merged for non-config-at-end issue. 48 private const val EPSILON = 0.05f 49 50 /** 51 * @return the ComponentName and user id of the top non-SystemUI activity in the pinned stack. 52 * The component name may be null if no such activity exists. 53 */ 54 @JvmStatic getTopPipActivitynull55 fun getTopPipActivity(context: Context): Pair<ComponentName?, Int> { 56 try { 57 val sysUiPackageName = context.packageName 58 val pinnedTaskInfo = ActivityTaskManager.getService().getRootTaskInfo( 59 WindowConfiguration.WINDOWING_MODE_PINNED, 60 WindowConfiguration.ACTIVITY_TYPE_UNDEFINED 61 ) 62 if (pinnedTaskInfo?.childTaskIds != null && pinnedTaskInfo.childTaskIds.isNotEmpty()) { 63 for (i in pinnedTaskInfo.childTaskNames.indices.reversed()) { 64 val cn = ComponentName.unflattenFromString( 65 pinnedTaskInfo.childTaskNames[i] 66 ) 67 if (cn != null && cn.packageName != sysUiPackageName) { 68 return Pair(cn, pinnedTaskInfo.childTaskUserIds[i]) 69 } 70 } 71 } 72 } catch (e: RemoteException) { 73 ProtoLog.w( 74 ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 75 "%s: Unable to get pinned stack.", TAG 76 ) 77 } 78 return Pair(null, 0) 79 } 80 81 /** 82 * @return the pixels for a given dp value. 83 */ 84 @JvmStatic dpToPxnull85 fun dpToPx(dpValue: Float, dm: DisplayMetrics?): Int { 86 return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, dm).toInt() 87 } 88 89 /** 90 * @return true if the aspect ratios differ 91 */ 92 @JvmStatic aspectRatioChangednull93 fun aspectRatioChanged(aspectRatio1: Float, aspectRatio2: Float): Boolean { 94 return abs(aspectRatio1 - aspectRatio2) > EPSILON 95 } 96 97 /** 98 * Checks whether title, description and intent match. 99 * Comparing icons would be good, but using equals causes false negatives 100 */ 101 @JvmStatic remoteActionsMatchnull102 fun remoteActionsMatch(action1: RemoteAction?, action2: RemoteAction?): Boolean { 103 if (action1 === action2) return true 104 if (action1 == null || action2 == null) return false 105 return action1.isEnabled == action2.isEnabled && 106 action1.shouldShowIcon() == action2.shouldShowIcon() && 107 action1.title == action2.title && 108 action1.contentDescription == action2.contentDescription && 109 action1.actionIntent == action2.actionIntent 110 } 111 112 /** 113 * Returns true if the actions in the lists match each other according to 114 * [ ][PipUtils.remoteActionsMatch], including their position. 115 */ 116 @JvmStatic remoteActionsChangednull117 fun remoteActionsChanged(list1: List<RemoteAction?>?, list2: List<RemoteAction?>?): Boolean { 118 if (list1 == null && list2 == null) { 119 return false 120 } 121 if (list1 == null || list2 == null) { 122 return true 123 } 124 if (list1.size != list2.size) { 125 return true 126 } 127 for (i in list1.indices) { 128 if (!remoteActionsMatch(list1[i], list2[i])) { 129 return true 130 } 131 } 132 return false 133 } 134 135 /** @return [TaskSnapshot] for a given task id. 136 */ 137 @JvmStatic getTaskSnapshotnull138 fun getTaskSnapshot(taskId: Int, isLowResolution: Boolean): TaskSnapshot? { 139 return if (taskId <= 0) null else try { 140 ActivityTaskManager.getService().getTaskSnapshot(taskId, isLowResolution) 141 } catch (e: RemoteException) { 142 Log.e(TAG, "Failed to get task snapshot, taskId=$taskId", e) 143 null 144 } 145 } 146 147 148 /** 149 * Returns a fake source rect hint for animation purposes when app-provided one is invalid. 150 * Resulting adjusted source rect hint lets the app icon in the content overlay to stay visible. 151 */ 152 @JvmStatic getEnterPipWithOverlaySrcRectHintnull153 fun getEnterPipWithOverlaySrcRectHint(appBounds: Rect, aspectRatio: Float): Rect { 154 val appBoundsAspRatio = appBounds.width().toFloat() / appBounds.height() 155 val width: Int 156 val height: Int 157 var left = appBounds.left 158 var top = appBounds.top 159 if (appBoundsAspRatio < aspectRatio) { 160 width = appBounds.width() 161 height = (width / aspectRatio).roundToInt() 162 top = appBounds.top + (appBounds.height() - height) / 2 163 } else { 164 height = appBounds.height() 165 width = (height * aspectRatio).roundToInt() 166 left = appBounds.left + (appBounds.width() - width) / 2 167 } 168 return Rect(left, top, left + width, top + height) 169 } 170 171 /** 172 * Temporary rounding "outward" (ie. -1.2 -> -2) used for crop since it is an int. We lean 173 * outward since, usually, child surfaces are, themselves, cropped, so we'd prefer to avoid 174 * inadvertently cutting out content that would otherwise be visible. 175 */ roundOutnull176 private fun roundOut(`val`: Float): Int { 177 return (if (`val` >= 0f) ceil(`val`) else floor(`val`)).toInt() 178 } 179 180 /** 181 * Calculates the transform to apply on a UNTRANSFORMED (config-at-end) Activity surface in 182 * order for it's hint-rect to occupy the same task-relative position/dimensions as it would 183 * have at the end of the transition (post-configuration). 184 * 185 * This is intended to be used in tandem with [calcStartTransform] below applied to the parent 186 * task. Applying both transforms simultaneously should result in the appearance of nothing 187 * having happened yet. 188 * 189 * Only the task should be animated (into it's identity state) and then WMCore will reset the 190 * activity transform in sync with its new configuration upon finish. 191 * 192 * Usage example: 193 * calcEndTransform(pipActivity, pipTask, scale, pos); 194 * t.setScale(pipActivity.getLeash(), scale.x, scale.y); 195 * t.setPosition(pipActivity.getLeash(), pos.x, pos.y); 196 * 197 * @see calcStartTransform 198 */ 199 @JvmStatic calcEndTransformnull200 fun calcEndTransform(pipActivity: TransitionInfo.Change, pipTask: TransitionInfo.Change, 201 outScale: PointF, outPos: PointF) { 202 val actStartBounds = pipActivity.startAbsBounds 203 val actEndBounds = pipActivity.endAbsBounds 204 val taskEndBounds = pipTask.endAbsBounds 205 206 var hintRect = pipTask.taskInfo?.pictureInPictureParams?.sourceRectHint 207 if (hintRect == null) { 208 hintRect = Rect(actStartBounds) 209 hintRect.offsetTo(0, 0) 210 } 211 212 // FA = final activity bounds (absolute) 213 // FT = final task bounds (absolute) 214 // SA = start activity bounds (absolute) 215 // H = source hint (relative to start activity bounds) 216 // We want to transform the activity so that when the task is at FT, H overlaps with FA 217 218 // This scales the activity such that the hint rect has the same dimensions 219 // as the final activity bounds. 220 val hintToEndScaleX = (actEndBounds.width().toFloat()) / (hintRect.width().toFloat()) 221 val hintToEndScaleY = (actEndBounds.height().toFloat()) / (hintRect.height().toFloat()) 222 // top-left needs to be (FA.tl - FT.tl) - H.tl * hintToEnd . H is relative to the 223 // activity; so, for example, if shrinking H to FA (hintToEnd < 1), then the tl of the 224 // shrunk SA is closer to H than expected, so we need to reduce how much we offset SA 225 // to get H.tl to match. 226 val startActPosInTaskEndX = 227 (actEndBounds.left - taskEndBounds.left) - hintRect.left * hintToEndScaleX 228 val startActPosInTaskEndY = 229 (actEndBounds.top - taskEndBounds.top) - hintRect.top * hintToEndScaleY 230 outScale.set(hintToEndScaleX, hintToEndScaleY) 231 outPos.set(startActPosInTaskEndX, startActPosInTaskEndY) 232 } 233 234 /** 235 * Calculates the transform and crop to apply on a Task surface in order for the config-at-end 236 * activity inside it (original-size activity transformed to match it's hint rect to the final 237 * Task bounds) to occupy the same world-space position/dimensions as it had before the 238 * transition. 239 * 240 * Intended to be used in tandem with [calcEndTransform]. 241 * 242 * Usage example: 243 * calcStartTransform(pipTask, scale, pos, crop); 244 * t.setScale(pipTask.getLeash(), scale.x, scale.y); 245 * t.setPosition(pipTask.getLeash(), pos.x, pos.y); 246 * t.setCrop(pipTask.getLeash(), crop); 247 * 248 * @see calcEndTransform 249 */ 250 @JvmStatic calcStartTransformnull251 fun calcStartTransform(pipTask: TransitionInfo.Change, outScale: PointF, 252 outPos: PointF, outCrop: Rect) { 253 val startBounds = pipTask.startAbsBounds 254 val taskEndBounds = pipTask.endAbsBounds 255 // For now, pip activity bounds always matches task bounds. If this ever changes, we'll 256 // need to get the activity offset. 257 val endBounds = taskEndBounds 258 var hintRect = pipTask.taskInfo?.pictureInPictureParams?.sourceRectHint 259 if (hintRect == null) { 260 hintRect = Rect(startBounds) 261 hintRect.offsetTo(0, 0) 262 } 263 264 // FA = final activity bounds (absolute) 265 // FT = final task bounds (absolute) 266 // SA = start activity bounds (absolute) 267 // H = source hint (relative to start activity bounds) 268 // We want to transform the activity so that when the task is at FT, H overlaps with FA 269 270 // The scaling which takes the hint rect (H) in SA and matches it to FA 271 val hintToEndScaleX = (endBounds.width().toFloat()) / (hintRect.width().toFloat()) 272 val hintToEndScaleY = (endBounds.height().toFloat()) / (hintRect.height().toFloat()) 273 274 // We want to set the transform on the END TASK surface to put the start activity 275 // back to where it was. 276 // First do backwards scale (which takes FA back to H) 277 val endToHintScaleX = 1f / hintToEndScaleX 278 val endToHintScaleY = 1f / hintToEndScaleY 279 // Then top-left needs to place FA (relative to the FT) at H (relative to SA): 280 // so -(FA.tl - FT.tl) + SA.tl + H.tl 281 // but we have scaled up the task, so anything that was "within" the task needs to 282 // be scaled: 283 // so -(FA.tl - FT.tl)*endToHint + SA.tl + H.tl 284 val endTaskPosForStartX = (-(endBounds.left - taskEndBounds.left) * endToHintScaleX 285 + startBounds.left + hintRect.left) 286 val endTaskPosForStartY = (-(endBounds.top - taskEndBounds.top) * endToHintScaleY 287 + startBounds.top + hintRect.top) 288 outScale.set(endToHintScaleX, endToHintScaleY) 289 outPos.set(endTaskPosForStartX, endTaskPosForStartY) 290 291 // now need to set crop to reveal the non-hint stuff. Again, hintrect is relative, so 292 // we must apply outsets to reveal the *activity* content which is *inside* the task 293 // and thus is scaled (ie. if activity is scaled down, each task-level pixel exposes 294 // >1 activity-level pixels) 295 // For example, the topleft crop would be: 296 // (FA.tl - FT.tl) - H.tl * hintToEnd 297 // ^ activity within task 298 // bottomright can just use scaled activity size 299 // tl + scale(SA.size, hintToEnd) 300 outCrop.left = roundOut((endBounds.left - taskEndBounds.left) 301 - hintRect.left * hintToEndScaleX) 302 outCrop.top = roundOut((endBounds.top - taskEndBounds.top) - hintRect.top * hintToEndScaleY) 303 outCrop.right = roundOut(outCrop.left + startBounds.width() * hintToEndScaleX) 304 outCrop.bottom = roundOut(outCrop.top + startBounds.height() * hintToEndScaleY) 305 } 306 307 private var isPip2ExperimentEnabled: Boolean? = null 308 309 /** 310 * Returns true if PiP2 implementation should be used. Besides the trunk stable flag, 311 * system property can be used to override this read only flag during development. 312 * It's currently limited to phone form factor, i.e., not enabled on ARC / TV. 313 */ 314 @JvmStatic isPip2ExperimentEnablednull315 fun isPip2ExperimentEnabled(): Boolean { 316 if (isPip2ExperimentEnabled == null) { 317 val isArc = AppGlobals.getPackageManager().hasSystemFeature( 318 "org.chromium.arc", 0) 319 val isTv = AppGlobals.getPackageManager().hasSystemFeature( 320 PackageManager.FEATURE_LEANBACK, 0) 321 isPip2ExperimentEnabled = Flags.enablePip2() && !isArc && !isTv 322 } 323 return isPip2ExperimentEnabled as Boolean 324 } 325 } 326