Add "change aspect ratio" to Recents/Overview

This shows up if the launcher is in a sw600dp-or-greater configuration.

Test: AspectRatioSystemShortcutTests
Test: OverviewMenuImageTest
Test: Manual check on phone, tablet, and inner/outer fold screens.
Flag: com.android.window.flags.universal_resizable_by_default
Bug: 357141415
Change-Id: I1bcbf9299d28ce68cccd636d92ed7195d64736eb
This commit is contained in:
Robin Lee
2025-02-04 14:27:19 +01:00
parent 88b2f23a83
commit 116eafb95c
6 changed files with 416 additions and 0 deletions
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2025 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.content.Intent
import android.provider.Settings
import android.view.View
import androidx.core.net.toUri
import com.android.launcher3.AbstractFloatingViewHelper
import com.android.launcher3.R
import com.android.launcher3.logging.StatsLogManager.LauncherEvent
import com.android.launcher3.popup.SystemShortcut
import com.android.quickstep.views.RecentsViewContainer
import com.android.quickstep.views.TaskContainer
import com.android.window.flags.Flags.universalResizableByDefault
/**
* System shortcut to change the application's aspect ratio compatibility mode.
*
* This shows up only on screens that are not compact, ie. shortest-width greater than {@link
* com.android.launcher3.util.window.WindowManagerProxy#MIN_TABLET_WIDTH}.
*/
class AspectRatioSystemShortcut(
viewContainer: RecentsViewContainer,
taskContainer: TaskContainer,
abstractFloatingViewHelper: AbstractFloatingViewHelper,
) :
SystemShortcut<RecentsViewContainer>(
R.drawable.ic_aspect_ratio,
R.string.recent_task_option_aspect_ratio,
viewContainer,
taskContainer.itemInfo,
taskContainer.taskView,
abstractFloatingViewHelper,
) {
override fun onClick(view: View) {
dismissTaskMenuView()
val intent =
Intent(Settings.ACTION_MANAGE_USER_ASPECT_RATIO_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (mItemInfo.targetPackage != null) {
intent.setData(("package:" + mItemInfo.targetPackage).toUri())
}
mTarget.startActivitySafely(view, intent, mItemInfo)
mTarget
.statsLogManager
.logger()
.withItemInfo(mItemInfo)
.log(LauncherEvent.LAUNCHER_ASPECT_RATIO_SETTINGS_SYSTEM_SHORTCUT_TAP)
}
companion object {
/** Optionally create a factory for the aspect ratio system shortcut. */
@JvmOverloads
fun createFactory(
abstractFloatingViewHelper: AbstractFloatingViewHelper = AbstractFloatingViewHelper()
): TaskShortcutFactory {
return object : TaskShortcutFactory {
override fun getShortcuts(
viewContainer: RecentsViewContainer,
taskContainer: TaskContainer,
): List<AspectRatioSystemShortcut>? {
return when {
// Only available when the feature flag is on.
!universalResizableByDefault() -> null
// The option is only shown on sw600dp+ screens (checked by isTablet)
!viewContainer.deviceProfile.isTablet -> null
else -> {
listOf(
AspectRatioSystemShortcut(
viewContainer,
taskContainer,
abstractFloatingViewHelper,
)
)
}
}
}
}
}
}
}
@@ -118,6 +118,7 @@ public class TaskOverlayFactory implements ResourceBasedOverride {
TaskShortcutFactory.FREE_FORM,
DesktopSystemShortcut.Companion.createFactory(),
ExternalDisplaySystemShortcut.Companion.createFactory(),
AspectRatioSystemShortcut.Companion.createFactory(),
TaskShortcutFactory.WELLBEING,
TaskShortcutFactory.SAVE_APP_PAIR,
TaskShortcutFactory.SCREENSHOT,
@@ -0,0 +1,285 @@
/*
* Copyright (C) 2025 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.content.ComponentName
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.platform.test.flag.junit.SetFlagsRule
import android.provider.Settings
import android.view.Display.DEFAULT_DISPLAY
import android.view.LayoutInflater
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.test.platform.app.InstrumentationRegistry
import com.android.launcher3.AbstractFloatingView
import com.android.launcher3.AbstractFloatingViewHelper
import com.android.launcher3.Flags.enableRefactorTaskThumbnail
import com.android.launcher3.InvariantDeviceProfile
import com.android.launcher3.R
import com.android.launcher3.logging.StatsLogManager
import com.android.launcher3.logging.StatsLogManager.LauncherEvent
import com.android.launcher3.logging.StatsLogManager.StatsLogger
import com.android.launcher3.model.data.ItemInfo
import com.android.launcher3.model.data.TaskViewItemInfo
import com.android.launcher3.util.RunnableList
import com.android.launcher3.util.SplitConfigurationOptions
import com.android.launcher3.util.TransformingTouchDelegate
import com.android.launcher3.util.WindowBounds
import com.android.quickstep.orientation.LandscapePagedViewHandler
import com.android.quickstep.recents.data.RecentsDeviceProfileRepository
import com.android.quickstep.recents.data.RecentsRotationStateRepository
import com.android.quickstep.recents.di.RecentsDependencies
import com.android.quickstep.task.thumbnail.TaskThumbnailView
import com.android.quickstep.util.RecentsOrientedState
import com.android.quickstep.views.LauncherRecentsView
import com.android.quickstep.views.RecentsViewContainer
import com.android.quickstep.views.TaskContainer
import com.android.quickstep.views.TaskThumbnailViewDeprecated
import com.android.quickstep.views.TaskView
import com.android.quickstep.views.TaskViewIcon
import com.android.systemui.shared.recents.model.Task
import com.android.systemui.shared.recents.model.Task.TaskKey
import com.android.window.flags.Flags
import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.eq
import org.mockito.Mockito.isNull
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
/** Test for [AspectRatioSystemShortcut] */
class AspectRatioSystemShortcutTests {
@get:Rule val setFlagsRule = SetFlagsRule(SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT)
/** Spy on a concrete Context so we can reference real View, Layout, and Display properties. */
private val context: Context = spy(InstrumentationRegistry.getInstrumentation().targetContext)
/**
* RecentsViewContainer and its super-interface ActivityContext contain methods to convert
* themselves to a Context at runtime, and static methods to convert a Context back to
* themselves by traversing ContextWrapper layers.
*
* Thus there is an undocumented assumption that a RecentsViewContainer always extends Context.
* We need to mock all of the RecentsViewContainer methods but leave the Context-under-test
* intact.
*
* The simplest way is to extend ContextWrapper and delegate the RecentsViewContainer interface
* to a mock.
*/
class RecentsViewContainerContextWrapper(base: Context) :
ContextWrapper(base), RecentsViewContainer by mock() {
private val statsLogManager: StatsLogManager = mock()
override fun getStatsLogManager(): StatsLogManager = statsLogManager
override fun startActivitySafely(v: View, intent: Intent, item: ItemInfo?): RunnableList? =
null
}
/**
* This <RecentsViewContainer & Context> is implicitly required in many parts of Launcher that
* require a Context. See RecentsViewContainerContextWrapper.
*/
private val launcher: RecentsViewContainerContextWrapper =
spy(RecentsViewContainerContextWrapper(context))
private val recentsView: LauncherRecentsView = mock()
private val abstractFloatingViewHelper: AbstractFloatingViewHelper = mock()
private val taskOverlayFactory: TaskOverlayFactory =
mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS)
private val factory: TaskShortcutFactory =
AspectRatioSystemShortcut.createFactory(abstractFloatingViewHelper)
private val statsLogger = mock<StatsLogger>()
private val orientedState: RecentsOrientedState =
mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS)
private val taskView: TaskView =
LayoutInflater.from(context).cloneInContext(launcher).inflate(R.layout.task, null) as
TaskView
@Before
fun setUp() {
whenever(launcher.getOverviewPanel<LauncherRecentsView>()).thenReturn(recentsView)
val statsLogManager = launcher.getStatsLogManager()
whenever(statsLogManager.logger()).thenReturn(statsLogger)
whenever(statsLogger.withItemInfo(any())).thenReturn(statsLogger)
whenever(orientedState.orientationHandler).thenReturn(LandscapePagedViewHandler())
taskView.setLayoutParams(ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
if (enableRefactorTaskThumbnail()) {
val recentsDependencies = RecentsDependencies.maybeInitialize(launcher)
val scopeId = recentsDependencies.createRecentsViewScope(launcher)
recentsDependencies.provide(
RecentsRotationStateRepository::class.java,
scopeId,
{ mock<RecentsRotationStateRepository>() }
)
recentsDependencies.provide(
RecentsDeviceProfileRepository::class.java,
scopeId,
{ mock<RecentsDeviceProfileRepository>() }
)
}
}
@After
fun tearDown() {
if (enableRefactorTaskThumbnail()) {
RecentsDependencies.destroy(launcher)
}
}
/**
* When the corresponding feature flag is off, there will not be an option to open aspect ratio
* settings.
*/
@DisableFlags(com.android.window.flags.Flags.FLAG_UNIVERSAL_RESIZABLE_BY_DEFAULT)
@Test
fun createShortcut_flaggedOff_notCreated() {
val task = createTask()
val taskContainer = createTaskContainer(task)
setScreenSizeDp(widthDp = 1200, heightDp = 800)
taskView.bind(task, orientedState, taskOverlayFactory)
assertThat(factory.getShortcuts(launcher, taskContainer)).isNull()
}
/**
* When the screen doesn't meet or exceed sw600dp (eg. phone, watch), there will not be an
* option to open aspect ratio settings.
*/
@EnableFlags(com.android.window.flags.Flags.FLAG_UNIVERSAL_RESIZABLE_BY_DEFAULT)
@Test
fun createShortcut_sw599dp_notCreated() {
val task = createTask()
val taskContainer = createTaskContainer(task)
setScreenSizeDp(widthDp = 599, heightDp = 599)
taskView.bind(task, orientedState, taskOverlayFactory)
assertThat(factory.getShortcuts(launcher, taskContainer)).isNull()
}
/**
* When the screen does meet or exceed sw600dp (eg. tablet, inner foldable screen, home cinema)
* there will be an option to open aspect ratio settings.
*/
@EnableFlags(com.android.window.flags.Flags.FLAG_UNIVERSAL_RESIZABLE_BY_DEFAULT)
@Test
fun createShortcut_sw800dp_created_andOpensSettings() {
val task = createTask()
val taskContainer = spy(createTaskContainer(task))
val taskViewItemInfo = mock<TaskViewItemInfo>()
doReturn(taskViewItemInfo).whenever(taskContainer).itemInfo
setScreenSizeDp(widthDp = 1200, heightDp = 800)
taskView.bind(task, orientedState, taskOverlayFactory)
val shortcuts = factory.getShortcuts(launcher, taskContainer)
assertThat(shortcuts).hasSize(1)
// On clicking the shortcut:
val shortcut = shortcuts!!.first() as AspectRatioSystemShortcut
shortcut.onClick(taskView)
// 1) Panel should be closed
val allTypesExceptRebindSafe =
AbstractFloatingView.TYPE_ALL and AbstractFloatingView.TYPE_REBIND_SAFE.inv()
verify(abstractFloatingViewHelper).closeOpenViews(launcher, true, allTypesExceptRebindSafe)
// 2) Compat mode settings activity should be launched
val intentCaptor = argumentCaptor<Intent>()
verify(launcher)
.startActivitySafely(any<View>(), intentCaptor.capture(), eq(taskViewItemInfo))
val intent = intentCaptor.firstValue!!
assertThat(intent.action).isEqualTo(Settings.ACTION_MANAGE_USER_ASPECT_RATIO_SETTINGS)
// 3) Shortcut tap event should be reported
verify(statsLogger).withItemInfo(taskViewItemInfo)
verify(statsLogger).log(LauncherEvent.LAUNCHER_ASPECT_RATIO_SETTINGS_SYSTEM_SHORTCUT_TAP)
}
/**
* Overrides the screen size reported in the DeviceProfile, keeping the same pixel density as
* the underlying device and adjusting the pixel width/height to match what is required.
*/
private fun setScreenSizeDp(widthDp: Int, heightDp: Int) {
val density = context.resources.configuration.densityDpi
val widthPx = widthDp * density / 160
val heightPx = heightDp * density / 160
val screenBounds = WindowBounds(widthPx, heightPx, widthPx, heightPx, Surface.ROTATION_0)
val deviceProfile =
InvariantDeviceProfile.INSTANCE[context].getDeviceProfile(context)
.toBuilder(context)
.setWindowBounds(screenBounds)
.build()
whenever(launcher.getDeviceProfile()).thenReturn(deviceProfile)
}
/** Create a (very) fake task for testing. */
private fun createTask() =
Task(
TaskKey(
/* id */ 1,
/* windowingMode */ 0,
Intent(),
ComponentName("", ""),
/* userId */ 0,
/* lastActiveTime */ 2000,
DEFAULT_DISPLAY,
ComponentName("", ""),
/* numActivities */ 1,
/* isTopActivityNoDisplay */ false,
/* isActivityStackTransparent */ false,
)
)
/** Create TaskContainer out of a given Task and fill in the rest with mocks. */
private fun createTaskContainer(task: Task) =
TaskContainer(
taskView,
task,
if (enableRefactorTaskThumbnail()) mock<TaskThumbnailView>()
else mock<TaskThumbnailViewDeprecated>(),
mock<TaskViewIcon>(),
mock<TransformingTouchDelegate>(),
SplitConfigurationOptions.STAGE_POSITION_UNDEFINED,
digitalWellBeingToast = null,
showWindowsView = null,
taskOverlayFactory,
)
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2025 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,12h-2v3h-3v2h5v-5zM7,9h3L10,7L5,7v5h2L7,9zM21,3L3,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM21,19.01L3,19.01L3,4.99h18v14.02z"/>
</vector>
+1
View File
@@ -42,6 +42,7 @@
<!-- Options for recent tasks -->
<!-- Title for an option to enter split screen mode for a given app -->
<string name="recent_task_option_split_screen">Split screen</string>
<string name="recent_task_option_aspect_ratio">Change aspect ratio</string>
<string name="split_app_info_accessibility">App info for %1$s</string>
<string name="split_app_usage_settings">Usage settings for %1$s</string>
@@ -865,6 +865,9 @@ public class StatsLogManager implements ResourceBasedOverride {
@UiEvent(doc = "User long press nav handle and a long press runnable was created.")
LAUNCHER_OMNI_GET_LONG_PRESS_RUNNABLE(1545),
@UiEvent(doc = "User tapped on \"change aspect ratio\" system shortcut.")
LAUNCHER_ASPECT_RATIO_SETTINGS_SYSTEM_SHORTCUT_TAP(2219),
// One Grid Flags
@UiEvent(doc = "User sets the device in Fixed Landscape")
FIXED_LANDSCAPE_TOGGLE_ENABLE(2014),