summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Robin Lee <rgl@google.com> 2025-02-04 14:27:19 +0100
committer Robin Lee <rgl@google.com> 2025-03-24 06:56:05 +0100
commit116eafb95c2d4538fb8b59c95a828823a2fe4148 (patch)
tree5a35f06ba420ccfbba6f4f58f19653713508e387
parent88b2f23a83252572a53cae7dbf4d27de54592553 (diff)
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
-rw-r--r--quickstep/src/com/android/quickstep/AspectRatioSystemShortcut.kt100
-rw-r--r--quickstep/src/com/android/quickstep/TaskOverlayFactory.java1
-rw-r--r--quickstep/tests/src/com/android/quickstep/AspectRatioSystemShortcutTests.kt285
-rw-r--r--res/drawable/ic_aspect_ratio.xml26
-rw-r--r--res/values/strings.xml1
-rw-r--r--src/com/android/launcher3/logging/StatsLogManager.java3
6 files changed, 416 insertions, 0 deletions
diff --git a/quickstep/src/com/android/quickstep/AspectRatioSystemShortcut.kt b/quickstep/src/com/android/quickstep/AspectRatioSystemShortcut.kt
new file mode 100644
index 0000000000..68860ac91b
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/AspectRatioSystemShortcut.kt
@@ -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,
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
index ae6bfbc517..5bf4451fad 100644
--- a/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
+++ b/quickstep/src/com/android/quickstep/TaskOverlayFactory.java
@@ -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,
diff --git a/quickstep/tests/src/com/android/quickstep/AspectRatioSystemShortcutTests.kt b/quickstep/tests/src/com/android/quickstep/AspectRatioSystemShortcutTests.kt
new file mode 100644
index 0000000000..10e85e6a1b
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/AspectRatioSystemShortcutTests.kt
@@ -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,
+ )
+}
diff --git a/res/drawable/ic_aspect_ratio.xml b/res/drawable/ic_aspect_ratio.xml
new file mode 100644
index 0000000000..aafaac4618
--- /dev/null
+++ b/res/drawable/ic_aspect_ratio.xml
@@ -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>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index a626097ea3..f0578cd16e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -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>
diff --git a/src/com/android/launcher3/logging/StatsLogManager.java b/src/com/android/launcher3/logging/StatsLogManager.java
index 40b597f838..aaf26d0aab 100644
--- a/src/com/android/launcher3/logging/StatsLogManager.java
+++ b/src/com/android/launcher3/logging/StatsLogManager.java
@@ -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),