diff options
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), |