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:
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user