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
17 @file:JvmName("DesktopModeUtils")
18
19 package com.android.wm.shell.desktopmode
20
21 import android.app.ActivityManager.RunningTaskInfo
22 import android.app.TaskInfo
23 import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
24 import android.content.Intent.FLAG_ACTIVITY_MULTIPLE_TASK
25 import android.content.pm.ActivityInfo.LAUNCH_MULTIPLE
26 import android.content.pm.ActivityInfo.LAUNCH_SINGLE_INSTANCE
27 import android.content.pm.ActivityInfo.LAUNCH_SINGLE_INSTANCE_PER_TASK
28 import android.content.pm.ActivityInfo.LAUNCH_SINGLE_TASK
29 import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
30 import android.content.pm.ActivityInfo.isFixedOrientationLandscape
31 import android.content.pm.ActivityInfo.isFixedOrientationPortrait
32 import android.content.res.Configuration.ORIENTATION_LANDSCAPE
33 import android.content.res.Configuration.ORIENTATION_PORTRAIT
34 import android.graphics.Rect
35 import android.os.SystemProperties
36 import android.util.Size
37 import android.window.DesktopModeFlags
38 import com.android.wm.shell.ShellTaskOrganizer
39 import com.android.wm.shell.common.DisplayController
40 import com.android.wm.shell.common.DisplayLayout
41 import kotlin.math.ceil
42
43 val DESKTOP_MODE_INITIAL_BOUNDS_SCALE: Float =
44 SystemProperties.getInt("persist.wm.debug.desktop_mode_initial_bounds_scale", 75) / 100f
45
46 val DESKTOP_MODE_LANDSCAPE_APP_PADDING: Int =
47 SystemProperties.getInt("persist.wm.debug.desktop_mode_landscape_app_padding", 25)
48
49 /** Calculates the initial bounds to enter desktop, centered on the display. */
calculateDefaultDesktopTaskBoundsnull50 fun calculateDefaultDesktopTaskBounds(displayLayout: DisplayLayout): Rect {
51 // TODO(b/319819547): Account for app constraints so apps do not become letterboxed
52 val desiredWidth = (displayLayout.width() * DESKTOP_MODE_INITIAL_BOUNDS_SCALE).toInt()
53 val desiredHeight = (displayLayout.height() * DESKTOP_MODE_INITIAL_BOUNDS_SCALE).toInt()
54 val heightOffset = (displayLayout.height() - desiredHeight) / 2
55 val widthOffset = (displayLayout.width() - desiredWidth) / 2
56 return Rect(widthOffset, heightOffset, desiredWidth + widthOffset, desiredHeight + heightOffset)
57 }
58
59 /**
60 * Calculates the initial bounds required for an application to fill a scale of the display bounds
61 * without any letterboxing. This is done by taking into account the applications fullscreen size,
62 * aspect ratio, orientation and resizability to calculate an area this is compatible with the
63 * applications previous configuration.
64 */
65 @JvmOverloads
calculateInitialBoundsnull66 fun calculateInitialBounds(
67 displayLayout: DisplayLayout,
68 taskInfo: RunningTaskInfo,
69 scale: Float = DESKTOP_MODE_INITIAL_BOUNDS_SCALE,
70 captionInsets: Int = 0,
71 requestedScreenOrientation: Int? = null,
72 ): Rect {
73 val screenBounds = Rect(0, 0, displayLayout.width(), displayLayout.height())
74 val appAspectRatio = calculateAspectRatio(taskInfo)
75 val idealSize = calculateIdealSize(screenBounds, scale)
76 // If no top activity exists, apps fullscreen bounds and aspect ratio cannot be calculated.
77 // Instead default to the desired initial bounds.
78 val stableBounds = Rect()
79 displayLayout.getStableBoundsForDesktopMode(stableBounds)
80 if (hasFullscreenOverride(taskInfo)) {
81 // If the activity has a fullscreen override applied, it should be treated as
82 // resizeable and match the device orientation. Thus the ideal size can be
83 // applied.
84 return positionInScreen(idealSize, stableBounds)
85 }
86 val topActivityInfo =
87 taskInfo.topActivityInfo ?: return positionInScreen(idealSize, stableBounds)
88 val screenOrientation = requestedScreenOrientation ?: topActivityInfo.screenOrientation
89
90 val initialSize: Size =
91 when (taskInfo.configuration.orientation) {
92 ORIENTATION_LANDSCAPE -> {
93 if (taskInfo.canChangeAspectRatio) {
94 if (isFixedOrientationPortrait(screenOrientation)) {
95 // For portrait resizeable activities, respect apps fullscreen width but
96 // apply ideal size height.
97 Size(
98 taskInfo.appCompatTaskInfo.topActivityAppBounds.width(),
99 idealSize.height,
100 )
101 } else {
102 // For landscape resizeable activities, simply apply ideal size.
103 idealSize
104 }
105 } else {
106 // If activity is unresizeable, regardless of orientation, calculate maximum
107 // size (within the ideal size) maintaining original aspect ratio.
108 maximizeSizeGivenAspectRatio(
109 taskInfo,
110 idealSize,
111 appAspectRatio,
112 captionInsets,
113 screenOrientation,
114 )
115 }
116 }
117 ORIENTATION_PORTRAIT -> {
118 val customPortraitWidthForLandscapeApp =
119 screenBounds.width() - (DESKTOP_MODE_LANDSCAPE_APP_PADDING * 2)
120 if (taskInfo.canChangeAspectRatio) {
121 if (isFixedOrientationLandscape(screenOrientation)) {
122 // For landscape resizeable activities, respect apps fullscreen height and
123 // apply custom app width.
124 Size(
125 customPortraitWidthForLandscapeApp,
126 taskInfo.appCompatTaskInfo.topActivityAppBounds.height(),
127 )
128 } else {
129 // For portrait resizeable activities, simply apply ideal size.
130 idealSize
131 }
132 } else {
133 if (isFixedOrientationLandscape(screenOrientation)) {
134 // For landscape unresizeable activities, apply custom app width to ideal
135 // size and calculate maximum size with this area while maintaining original
136 // aspect ratio.
137 maximizeSizeGivenAspectRatio(
138 taskInfo,
139 Size(customPortraitWidthForLandscapeApp, idealSize.height),
140 appAspectRatio,
141 captionInsets,
142 screenOrientation,
143 )
144 } else {
145 // For portrait unresizeable activities, calculate maximum size (within the
146 // ideal size) maintaining original aspect ratio.
147 maximizeSizeGivenAspectRatio(
148 taskInfo,
149 idealSize,
150 appAspectRatio,
151 captionInsets,
152 screenOrientation,
153 )
154 }
155 }
156 }
157 else -> {
158 idealSize
159 }
160 }
161
162 return positionInScreen(initialSize, stableBounds)
163 }
164
165 /**
166 * Calculates the maximized bounds of a task given in the given [DisplayLayout], taking resizability
167 * into consideration.
168 */
calculateMaximizeBoundsnull169 fun calculateMaximizeBounds(displayLayout: DisplayLayout, taskInfo: RunningTaskInfo): Rect {
170 val stableBounds = Rect()
171 displayLayout.getStableBounds(stableBounds)
172 if (taskInfo.isResizeable) {
173 // if resizable then expand to entire stable bounds (full display minus insets)
174 return Rect(stableBounds)
175 } else {
176 // if non-resizable then calculate max bounds according to aspect ratio
177 val activityAspectRatio = calculateAspectRatio(taskInfo)
178 val captionInsets =
179 taskInfo.configuration.windowConfiguration.appBounds?.let {
180 it.top - taskInfo.configuration.windowConfiguration.bounds.top
181 } ?: 0
182 val newSize =
183 maximizeSizeGivenAspectRatio(
184 taskInfo,
185 Size(stableBounds.width(), stableBounds.height()),
186 activityAspectRatio,
187 captionInsets,
188 )
189 return centerInArea(newSize, stableBounds, stableBounds.left, stableBounds.top)
190 }
191 }
192
193 /**
194 * Calculates the largest size that can fit in a given area while maintaining a specific aspect
195 * ratio.
196 */
maximizeSizeGivenAspectRationull197 fun maximizeSizeGivenAspectRatio(
198 taskInfo: RunningTaskInfo,
199 targetArea: Size,
200 aspectRatio: Float,
201 captionInsets: Int = 0,
202 requestedScreenOrientation: Int? = null,
203 ): Size {
204 val targetHeight = targetArea.height - captionInsets
205 val targetWidth = targetArea.width
206 val finalHeight: Int
207 val finalWidth: Int
208 // Get orientation either through top activity or task's orientation
209 val screenOrientation =
210 requestedScreenOrientation ?: taskInfo.topActivityInfo?.screenOrientation
211 if (taskInfo.hasPortraitTopActivity(screenOrientation)) {
212 val tempWidth = ceil(targetHeight / aspectRatio).toInt()
213 if (tempWidth <= targetWidth) {
214 finalHeight = targetHeight
215 finalWidth = tempWidth
216 } else {
217 finalWidth = targetWidth
218 finalHeight = ceil(finalWidth * aspectRatio).toInt()
219 }
220 } else {
221 val tempWidth = ceil(targetHeight * aspectRatio).toInt()
222 if (tempWidth <= targetWidth) {
223 finalHeight = targetHeight
224 finalWidth = tempWidth
225 } else {
226 finalWidth = targetWidth
227 finalHeight = ceil(finalWidth / aspectRatio).toInt()
228 }
229 }
230 return Size(finalWidth, finalHeight + captionInsets)
231 }
232
233 /** Calculates the aspect ratio of an activity from its fullscreen bounds. */
calculateAspectRationull234 fun calculateAspectRatio(taskInfo: RunningTaskInfo): Float {
235 val appBounds =
236 if (taskInfo.appCompatTaskInfo.topActivityAppBounds.isEmpty) {
237 taskInfo.configuration.windowConfiguration.appBounds
238 ?: taskInfo.configuration.windowConfiguration.bounds
239 } else {
240 taskInfo.appCompatTaskInfo.topActivityAppBounds
241 }
242 return maxOf(appBounds.height(), appBounds.width()) /
243 minOf(appBounds.height(), appBounds.width()).toFloat()
244 }
245
246 /** Returns whether the task is maximized. */
isTaskMaximizednull247 fun isTaskMaximized(taskInfo: RunningTaskInfo, displayController: DisplayController): Boolean {
248 val displayLayout =
249 displayController.getDisplayLayout(taskInfo.displayId)
250 ?: error("Could not get display layout for display=${taskInfo.displayId}")
251 val stableBounds = Rect()
252 displayLayout.getStableBounds(stableBounds)
253 return isTaskMaximized(taskInfo, stableBounds)
254 }
255
256 /** Returns whether the task is maximized. */
isTaskMaximizednull257 fun isTaskMaximized(taskInfo: RunningTaskInfo, stableBounds: Rect): Boolean {
258 val currentTaskBounds = taskInfo.configuration.windowConfiguration.bounds
259 return if (taskInfo.isResizeable) {
260 isTaskBoundsEqual(currentTaskBounds, stableBounds)
261 } else {
262 isTaskWidthOrHeightEqual(currentTaskBounds, stableBounds)
263 }
264 }
265
266 /** Returns true if task's width or height is maximized else returns false. */
isTaskWidthOrHeightEqualnull267 fun isTaskWidthOrHeightEqual(taskBounds: Rect, stableBounds: Rect): Boolean {
268 return taskBounds.width() == stableBounds.width() ||
269 taskBounds.height() == stableBounds.height()
270 }
271
272 /** Returns true if task bound is equal to stable bounds else returns false. */
isTaskBoundsEqualnull273 fun isTaskBoundsEqual(taskBounds: Rect, stableBounds: Rect): Boolean {
274 return taskBounds == stableBounds
275 }
276
277 /**
278 * Returns the task bounds a launching task should inherit from an existing running instance.
279 * Returns null if there are no bounds to inherit.
280 */
getInheritedExistingTaskBoundsnull281 fun getInheritedExistingTaskBounds(
282 taskRepository: DesktopRepository,
283 shellTaskOrganizer: ShellTaskOrganizer,
284 task: RunningTaskInfo,
285 deskId: Int,
286 ): Rect? {
287 if (!DesktopModeFlags.INHERIT_TASK_BOUNDS_FOR_TRAMPOLINE_TASK_LAUNCHES.isTrue) return null
288 val activeTask = taskRepository.getExpandedTasksIdsInDeskOrdered(deskId).firstOrNull()
289 if (activeTask == null) return null
290 val lastTask = shellTaskOrganizer.getRunningTaskInfo(activeTask)
291 val lastTaskTopActivity = lastTask?.topActivity
292 val currentTaskTopActivity = task.topActivity
293 val intentFlags = task.baseIntent.flags
294 val launchMode = task.topActivityInfo?.launchMode ?: LAUNCH_MULTIPLE
295 return when {
296 // No running task activity to inherit bounds from.
297 lastTaskTopActivity == null -> null
298 // No current top activity to set bounds for.
299 currentTaskTopActivity == null -> null
300 // Top task is not an instance of the launching activity, do not inherit its bounds.
301 lastTaskTopActivity.packageName != currentTaskTopActivity.packageName -> null
302 // Top task is an instance of launching activity. Activity will be launching in a new
303 // task with the existing task also being closed. Inherit existing task bounds to
304 // prevent new task jumping.
305 (isLaunchingNewSingleTask(launchMode) && isClosingExitingInstance(intentFlags)) ->
306 lastTask.configuration.windowConfiguration.bounds
307 else -> null
308 }
309 }
310
311 /**
312 * Returns true if the launch mode will result in a single new task being created for the activity.
313 */
isLaunchingNewSingleTasknull314 private fun isLaunchingNewSingleTask(launchMode: Int) =
315 launchMode == LAUNCH_SINGLE_TASK ||
316 launchMode == LAUNCH_SINGLE_INSTANCE ||
317 launchMode == LAUNCH_SINGLE_INSTANCE_PER_TASK
318
319 /**
320 * Returns true if the intent will result in an existing task instance being closed if a new one
321 * appears.
322 */
323 private fun isClosingExitingInstance(intentFlags: Int) =
324 (intentFlags and FLAG_ACTIVITY_CLEAR_TASK) != 0 ||
325 (intentFlags and FLAG_ACTIVITY_MULTIPLE_TASK) == 0
326
327 /**
328 * Calculates the desired initial bounds for applications in desktop windowing. This is done as a
329 * scale of the screen bounds.
330 */
331 private fun calculateIdealSize(screenBounds: Rect, scale: Float): Size {
332 val width = (screenBounds.width() * scale).toInt()
333 val height = (screenBounds.height() * scale).toInt()
334 return Size(width, height)
335 }
336
337 /** Adjusts bounds to be positioned in the middle of the screen. */
positionInScreennull338 private fun positionInScreen(desiredSize: Size, stableBounds: Rect): Rect =
339 Rect(0, 0, desiredSize.width, desiredSize.height).apply {
340 val offset = DesktopTaskPosition.Center.getTopLeftCoordinates(stableBounds, this)
341 offsetTo(offset.x, offset.y)
342 }
343
344 /**
345 * Whether the activity's aspect ratio can be changed or if it should be maintained as if it was
346 * unresizeable.
347 */
348 private val TaskInfo.canChangeAspectRatio: Boolean
349 get() = isResizeable && !appCompatTaskInfo.hasMinAspectRatioOverride()
350
351 /**
352 * Adjusts bounds to be positioned in the middle of the area provided, not necessarily the entire
353 * screen, as area can be offset by left and top start.
354 */
centerInAreanull355 fun centerInArea(desiredSize: Size, areaBounds: Rect, leftStart: Int, topStart: Int): Rect {
356 val heightOffset = (areaBounds.height() - desiredSize.height) / 2
357 val widthOffset = (areaBounds.width() - desiredSize.width) / 2
358
359 val newLeft = leftStart + widthOffset
360 val newTop = topStart + heightOffset
361 val newRight = newLeft + desiredSize.width
362 val newBottom = newTop + desiredSize.height
363
364 return Rect(newLeft, newTop, newRight, newBottom)
365 }
366
TaskInfonull367 private fun TaskInfo.hasPortraitTopActivity(screenOrientation: Int?): Boolean {
368 val topActivityScreenOrientation = screenOrientation ?: SCREEN_ORIENTATION_UNSPECIFIED
369 val appBounds = configuration.windowConfiguration.appBounds
370
371 return when {
372 // First check if activity has portrait screen orientation
373 topActivityScreenOrientation != SCREEN_ORIENTATION_UNSPECIFIED -> {
374 isFixedOrientationPortrait(topActivityScreenOrientation)
375 }
376
377 // Then check if the activity is portrait when letterboxed
378 appCompatTaskInfo.isTopActivityLetterboxed -> appCompatTaskInfo.isTopActivityPillarboxShaped
379
380 // Then check if the activity is portrait
381 appBounds != null -> appBounds.height() > appBounds.width()
382
383 // Otherwise just take the orientation of the task
384 else -> isFixedOrientationPortrait(configuration.orientation)
385 }
386 }
387
hasFullscreenOverridenull388 private fun hasFullscreenOverride(taskInfo: RunningTaskInfo): Boolean {
389 return taskInfo.appCompatTaskInfo.isUserFullscreenOverrideEnabled ||
390 taskInfo.appCompatTaskInfo.isSystemFullscreenOverrideEnabled
391 }
392