diff options
Diffstat (limited to 'quickstep')
8 files changed, 575 insertions, 32 deletions
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt index eac5235dda..06e6734a37 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt +++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt @@ -25,6 +25,7 @@ import com.android.launcher3.AbstractFloatingView import com.android.launcher3.R import com.android.launcher3.Utilities.EDGE_NAV_BAR import com.android.launcher3.Utilities.boundToRange +import com.android.launcher3.Utilities.debugLog import com.android.launcher3.Utilities.isRtl import com.android.launcher3.Utilities.mapToRange import com.android.launcher3.touch.SingleAxisSwipeDetector @@ -70,6 +71,7 @@ CONTAINER : RecentsViewContainer { // Don't intercept swipes on the nav bar, as user might be trying to go home during a // task dismiss animation. (ev.edgeFlags and EDGE_NAV_BAR) != 0 -> { + debugLog(TAG, "Not intercepting edge swipe on nav bar.") false } @@ -77,14 +79,23 @@ CONTAINER : RecentsViewContainer { AbstractFloatingView.getTopOpenViewWithType( container, AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT, - ) != null -> false + ) != null -> { + debugLog(TAG, "Not intercepting, open floating view blocking touch.") + false + } // Disable swiping if the task overlay is modal. taskViewRecentsTouchContext.isRecentsModal -> { + debugLog(TAG, "Not intercepting touch in modal overlay.") false } - else -> taskViewRecentsTouchContext.isRecentsInteractive + else -> + taskViewRecentsTouchContext.isRecentsInteractive.also { isRecentsInteractive -> + if (!isRecentsInteractive) { + debugLog(TAG, "Not intercepting touch, recents not interactive.") + } + } } override fun onControllerInterceptTouchEvent(ev: MotionEvent): Boolean { @@ -140,6 +151,7 @@ CONTAINER : RecentsViewContainer { override fun onDragStart(start: Boolean, startDisplacement: Float) { if (isBlockedDuringDismissal) return val taskBeingDragged = taskBeingDragged ?: return + debugLog(TAG, "Handling touch event.") initialDisplacement = taskBeingDragged.secondaryDismissTranslationProperty.get(taskBeingDragged) @@ -289,6 +301,8 @@ CONTAINER : RecentsViewContainer { } companion object { + private const val TAG = "TaskViewDismissTouchController" + private const val DISMISS_THRESHOLD_FRACTION = 0.5f private const val DISMISS_THRESHOLD_HAPTIC_RANGE = 10f diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewLaunchTouchController.kt b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewLaunchTouchController.kt index 8ee552d046..fe9cae55f9 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewLaunchTouchController.kt +++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewLaunchTouchController.kt @@ -24,6 +24,7 @@ import com.android.launcher3.AbstractFloatingView import com.android.launcher3.LauncherAnimUtils import com.android.launcher3.Utilities.EDGE_NAV_BAR import com.android.launcher3.Utilities.boundToRange +import com.android.launcher3.Utilities.debugLog import com.android.launcher3.Utilities.isRtl import com.android.launcher3.anim.AnimatorPlaybackController import com.android.launcher3.touch.BaseSwipeDetector @@ -72,6 +73,7 @@ CONTAINER : RecentsViewContainer { // Don't intercept swipes on the nav bar, as user might be trying to go home during a // task dismiss animation. (ev.edgeFlags and EDGE_NAV_BAR) != 0 -> { + debugLog(TAG, "Not intercepting edge swipe on nav bar.") false } @@ -80,15 +82,22 @@ CONTAINER : RecentsViewContainer { container, AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT, ) != null -> { + debugLog(TAG, "Not intercepting, open floating view blocking touch.") false } // Disable swiping if the task overlay is modal. taskViewRecentsTouchContext.isRecentsModal -> { + debugLog(TAG, "Not intercepting touch in modal overlay.") false } - else -> taskViewRecentsTouchContext.isRecentsInteractive + else -> + taskViewRecentsTouchContext.isRecentsInteractive.also { isRecentsInteractive -> + if (!isRecentsInteractive) { + debugLog(TAG, "Not intercepting touch, recents not interactive.") + } + } } override fun onControllerInterceptTouchEvent(ev: MotionEvent): Boolean { @@ -128,6 +137,7 @@ CONTAINER : RecentsViewContainer { recentsView.pagedOrientationHandler.getTaskDragDisplacementFactor(isRtl) } if (!canTaskLaunchTaskView(taskBeingDragged)) { + debugLog(TAG, "Not intercepting touch, task cannot be launched.") return false } detector.setDetectableScrollConditions(downDirection, /* ignoreSlop= */ false) @@ -136,6 +146,7 @@ CONTAINER : RecentsViewContainer { override fun onDragStart(start: Boolean, startDisplacement: Float) { val taskBeingDragged = taskBeingDragged ?: return + debugLog(TAG, "Handling touch event.") val secondaryLayerDimension: Int = recentsView.pagedOrientationHandler.getSecondaryDimension(container.getDragLayer()) @@ -202,6 +213,7 @@ CONTAINER : RecentsViewContainer { } companion object { + private const val TAG = "TaskViewLaunchTouchController" private const val LAUNCH_THRESHOLD_FRACTION: Float = 0.5f } } diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchControllerDeprecated.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchControllerDeprecated.java index f26bd13586..57ffd9538d 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchControllerDeprecated.java +++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewTouchControllerDeprecated.java @@ -17,6 +17,7 @@ package com.android.launcher3.uioverrides.touchcontrollers; import static com.android.launcher3.AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT; import static com.android.launcher3.LauncherAnimUtils.SUCCESS_TRANSITION_PROGRESS; +import static com.android.launcher3.Utilities.debugLog; import static com.android.launcher3.touch.SingleAxisSwipeDetector.DIRECTION_BOTH; import android.animation.Animator; @@ -56,6 +57,7 @@ import com.android.quickstep.views.TaskView; public class TaskViewTouchControllerDeprecated< CONTAINER extends Context & RecentsViewContainer> extends AnimatorListenerAdapter implements TouchController, SingleAxisSwipeDetector.Listener { + private static final String TAG = "TaskViewTouchControllerDeprecated"; private static final float ANIMATION_PROGRESS_FRACTION_MIDPOINT = 0.5f; private static final long MIN_TASK_DISMISS_ANIMATION_DURATION = 300; @@ -110,6 +112,7 @@ public class TaskViewTouchControllerDeprecated< if (mCurrentAnimation != null) { mCurrentAnimation.getAnimationPlayer().end(); } + debugLog(TAG, "Not intercepting edge swipe on nav bar."); return false; } if (mCurrentAnimation != null) { @@ -121,6 +124,7 @@ public class TaskViewTouchControllerDeprecated< } if (AbstractFloatingView.getTopOpenViewWithType( mContainer, TYPE_TOUCH_CONTROLLER_NO_INTERCEPT) != null) { + debugLog(TAG, "Not intercepting, open floating view blocking touch."); return false; } return mTaskViewRecentsTouchContext.isRecentsInteractive(); @@ -142,6 +146,7 @@ public class TaskViewTouchControllerDeprecated< if (ev.getAction() == MotionEvent.ACTION_DOWN) { mNoIntercept = !canInterceptTouch(ev); if (mNoIntercept) { + debugLog(TAG, "Not intercepting touch."); return false; } @@ -186,6 +191,7 @@ public class TaskViewTouchControllerDeprecated< } if (mTaskBeingDragged == null) { mNoIntercept = true; + debugLog(TAG, "Not intercepting touch, no task to drag."); return false; } } @@ -195,6 +201,7 @@ public class TaskViewTouchControllerDeprecated< } if (mNoIntercept) { + debugLog(TAG, "Not intercepting touch."); return false; } @@ -266,6 +273,7 @@ public class TaskViewTouchControllerDeprecated< @Override public void onDragStart(boolean start, float startDisplacement) { if (!mDraggingEnabled) return; + debugLog(TAG, "Handling touch."); RecentsPagedOrientationHandler orientationHandler = mRecentsView.getPagedOrientationHandler(); 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/LauncherBackAnimationController.java b/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java index 783ec2c7da..c6785629b0 100644 --- a/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java +++ b/quickstep/src/com/android/quickstep/LauncherBackAnimationController.java @@ -26,6 +26,7 @@ import static com.android.launcher3.BaseActivity.INVISIBLE_ALL; import static com.android.launcher3.BaseActivity.INVISIBLE_BY_PENDING_FLAGS; import static com.android.launcher3.BaseActivity.PENDING_INVISIBLE_BY_WALLPAPER_ANIMATION; import static com.android.window.flags.Flags.predictiveBackThreeButtonNav; +import static com.android.window.flags.Flags.removeDepartTargetFromMotion; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; @@ -121,6 +122,7 @@ public class LauncherBackAnimationController { private final SurfaceControl.Transaction mTransaction = new SurfaceControl.Transaction(); private float mBackProgress = 0; private boolean mBackInProgress = false; + private boolean mWaitStartTransition = false; private OnBackInvokedCallbackStub mBackCallback; private IRemoteAnimationFinishedCallback mAnimationFinishedCallback; private final BackProgressAnimator mProgressAnimator = new BackProgressAnimator(); @@ -158,7 +160,8 @@ public class LauncherBackAnimationController { mBackCallback = new OnBackInvokedCallbackStub(handler, mProgressAnimator, mProgressInterpolator, this); SystemUiProxy.INSTANCE.get(mLauncher).setBackToLauncherCallback(mBackCallback, - new RemoteAnimationRunnerStub(this)); + new RemoteAnimationRunnerStub(this, + removeDepartTargetFromMotion() ? handler : null)); } private static class OnBackInvokedCallbackStub extends IOnBackInvokedCallback.Stub { @@ -195,7 +198,14 @@ public class LauncherBackAnimationController { mHandler.post(() -> { LauncherBackAnimationController controller = mControllerRef.get(); if (controller != null) { - controller.startTransition(); + if (!removeDepartTargetFromMotion()) { + controller.startTransition(); + } else { + controller.mWaitStartTransition = true; + if (controller.mBackTarget != null && controller.mBackInProgress) { + controller.startTransition(); + } + } } mProgressAnimator.reset(); }); @@ -220,7 +230,8 @@ public class LauncherBackAnimationController { mHandler.post(() -> { LauncherBackAnimationController controller = mControllerRef.get(); if (controller != null) { - controller.startBack(backEvent); + controller.initBackMotion(backEvent); + controller.tryStartBackAnimation(); mProgressAnimator.onBackStarted(backEvent, event -> { float backProgress = event.getProgress(); controller.mBackProgress = @@ -248,9 +259,12 @@ public class LauncherBackAnimationController { // LauncherBackAnimationController has strong reference to Launcher activity, the binder // callback should not hold strong reference to it to avoid memory leak. private WeakReference<LauncherBackAnimationController> mControllerRef; + private final Handler mHandler; - private RemoteAnimationRunnerStub(LauncherBackAnimationController controller) { + private RemoteAnimationRunnerStub(LauncherBackAnimationController controller, + Handler handler) { mControllerRef = new WeakReference<>(controller); + mHandler = handler; } @Override @@ -261,15 +275,29 @@ public class LauncherBackAnimationController { if (controller == null) { return; } - for (final RemoteAnimationTarget target : apps) { - if (MODE_CLOSING == target.mode) { - controller.mBackTarget = target; + final Runnable r = () -> { + for (final RemoteAnimationTarget target : apps) { + if (MODE_CLOSING == target.mode) { + controller.mBackTarget = target; + } + if (MODE_OPENING == target.mode) { + controller.mLauncherTarget = target; + } } - if (MODE_OPENING == target.mode) { - controller.mLauncherTarget = target; + controller.mAnimationFinishedCallback = finishedCallback; + if (!removeDepartTargetFromMotion()) { + return; + } + controller.tryStartBackAnimation(); + if (controller.mWaitStartTransition) { + controller.startTransition(); } + }; + if (mHandler != null) { + mHandler.post(r); + } else { + r.run(); } - controller.mAnimationFinishedCallback = finishedCallback; } @Override @@ -294,34 +322,39 @@ public class LauncherBackAnimationController { mBackCallback = null; } - private void startBack(BackMotionEvent backEvent) { + private void initBackMotion(BackMotionEvent backEvent) { // in case we're still animating an onBackCancelled event, let's remove the finish- // callback from the progress animator to prevent calling finishAnimation() before // restarting a new animation - // Side note: startBack is never called during the post-commit phase if the back gesture - // was committed (not cancelled). BackAnimationController prevents that. Therefore we - // don't have to handle that case. + // Side note: initBackMotion is never called during the post-commit phase if the back + // gesture was committed (not cancelled). BackAnimationController prevents that. Therefore + // we don't have to handle that case. mProgressAnimator.removeOnBackCancelledFinishCallback(); + if (!removeDepartTargetFromMotion()) { + RemoteAnimationTarget appTarget = backEvent.getDepartingAnimationTarget(); + if (appTarget == null || appTarget.leash == null || !appTarget.leash.isValid()) { + return; + } + mBackTarget = appTarget; + } mBackInProgress = true; - RemoteAnimationTarget appTarget = backEvent.getDepartingAnimationTarget(); - - if (appTarget == null || appTarget.leash == null || !appTarget.leash.isValid()) { + mInitialTouchPos.set(backEvent.getTouchX(), backEvent.getTouchY()); + } + private void tryStartBackAnimation() { + if (mBackTarget == null || (removeDepartTargetFromMotion() && !mBackInProgress)) { return; } mTransaction - .show(appTarget.leash) + .show(mBackTarget.leash) .setAnimationTransaction(); - mBackTarget = appTarget; - mInitialTouchPos.set(backEvent.getTouchX(), backEvent.getTouchY()); - - mStartRect.set(appTarget.windowConfiguration.getMaxBounds()); + mStartRect.set(mBackTarget.windowConfiguration.getMaxBounds()); // inset bottom in case of taskbar being present if (!predictiveBackThreeButtonNav() || mLauncher.getDeviceProfile().isTaskbarPresent || DisplayController.getNavigationMode(mLauncher) == NavigationMode.NO_BUTTON) { - mStartRect.inset(0, 0, 0, appTarget.contentInsets.bottom); + mStartRect.inset(0, 0, 0, mBackTarget.contentInsets.bottom); } mLauncherTargetView = mQuickstepTransitionManager.findLauncherView( @@ -466,10 +499,14 @@ public class LauncherBackAnimationController { } private void startTransition() { - if (mBackTarget == null) { - // Trigger transition system instead of custom transition animation. - finishAnimation(); - return; + if (!removeDepartTargetFromMotion()) { + if (mBackTarget == null) { + // Trigger transition system instead of custom transition animation. + finishAnimation(); + return; + } + } else { + mWaitStartTransition = false; } if (mLauncher.isDestroyed()) { return; 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/quickstep/tests/src/com/android/quickstep/DesktopSystemShortcutTest.kt b/quickstep/tests/src/com/android/quickstep/DesktopSystemShortcutTest.kt index 5f61ba2e07..8a2393d539 100644 --- a/quickstep/tests/src/com/android/quickstep/DesktopSystemShortcutTest.kt +++ b/quickstep/tests/src/com/android/quickstep/DesktopSystemShortcutTest.kt @@ -16,9 +16,11 @@ package com.android.quickstep +import android.Manifest.permission.SYSTEM_ALERT_WINDOW import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.platform.test.annotations.DisableFlags import android.platform.test.annotations.EnableFlags @@ -44,6 +46,7 @@ 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.quickstep.views.TaskViewType import com.android.systemui.shared.recents.model.Task import com.android.systemui.shared.recents.model.Task.TaskKey import com.android.window.flags.Flags @@ -54,6 +57,8 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.`when` import org.mockito.kotlin.any import org.mockito.kotlin.doReturn @@ -74,12 +79,12 @@ class DesktopSystemShortcutTest { private val statsLogManager: StatsLogManager = mock() private val statsLogger: StatsLogManager.StatsLogger = mock() private val recentsView: LauncherRecentsView = mock() - private val taskView: TaskView = mock() private val abstractFloatingViewHelper: AbstractFloatingViewHelper = mock() private val overlayFactory: TaskOverlayFactory = mock() private val factory: TaskShortcutFactory = DesktopSystemShortcut.createFactory(abstractFloatingViewHelper) private val context: Context = spy(InstrumentationRegistry.getInstrumentation().targetContext) + private val taskView: TaskView = createTaskViewMock() private lateinit var mockitoSession: StaticMockitoSession @@ -135,6 +140,64 @@ class DesktopSystemShortcutTest { } @Test + @EnableFlags( + Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODALS_POLICY, + Flags.FLAG_ENABLE_MODALS_FULLSCREEN_WITH_PERMISSION, + ) + fun createDesktopTaskShortcutFactoryPermissionEnabledAllowed_transparentTask() { + val packageManager: PackageManager = mock() + setUpTransparentPermission(packageManager, isAllowed = true) + val baseComponent = ComponentName("", /* class */ "") + val taskKey = + TaskKey( + /* id */ 1, + /* windowingMode */ 0, + Intent(), + baseComponent, + /* userId */ 0, + /* lastActiveTime */ 2000, + DEFAULT_DISPLAY, + baseComponent, + /* numActivities */ 1, + /* isTopActivityNoDisplay */ false, + /* isActivityStackTransparent */ true, + ) + val taskContainer = createTaskContainer(Task(taskKey)) + val shortcuts = factory.getShortcuts(launcher, taskContainer) + assertThat(shortcuts).isNull() + } + + @Test + @EnableFlags( + Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODALS_POLICY, + Flags.FLAG_ENABLE_MODALS_FULLSCREEN_WITH_PERMISSION, + ) + fun createDesktopTaskShortcutFactoryPermissionEnabledNotAllowed_transparentTask() { + val packageManager: PackageManager = mock() + setUpTransparentPermission(packageManager, isAllowed = false) + val baseComponent = ComponentName("", /* class */ "") + val homeActivities = ComponentName("defaultHomePackage", /* class */ "") + whenever(packageManager.getHomeActivities(any())).thenReturn(homeActivities) + val taskKey = + TaskKey( + /* id */ 1, + /* windowingMode */ 0, + Intent(), + baseComponent, + /* userId */ 0, + /* lastActiveTime */ 2000, + DEFAULT_DISPLAY, + baseComponent, + /* numActivities */ 1, + /* isTopActivityNoDisplay */ false, + /* isActivityStackTransparent */ true, + ) + val taskContainer = createTaskContainer(Task(taskKey).apply { isDockable = true }) + val shortcuts = factory.getShortcuts(launcher, taskContainer) + assertThat(shortcuts).isNotEmpty() + } + + @Test @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODALS_POLICY) fun createDesktopTaskShortcutFactory_systemUiTask() { val sysUiPackageName: String = context.resources.getString(R.string.config_systemUi) @@ -162,8 +225,8 @@ class DesktopSystemShortcutTest { @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_MODALS_POLICY) fun createDesktopTaskShortcutFactory_defaultHomeTask() { val packageManager: PackageManager = mock() - val homeActivities = ComponentName("defaultHomePackage", /* class */ "") whenever(context.packageManager).thenReturn(packageManager) + val homeActivities = ComponentName("defaultHomePackage", /* class */ "") whenever(packageManager.getHomeActivities(any())).thenReturn(homeActivities) val taskKey = TaskKey( @@ -262,4 +325,27 @@ class DesktopSystemShortcutTest { showWindowsView = null, overlayFactory, ) + + private fun setUpTransparentPermission(packageManager: PackageManager, isAllowed: Boolean) { + val packageInfo: PackageInfo = mock() + if (isAllowed) { + packageInfo.requestedPermissions = arrayOf(SYSTEM_ALERT_WINDOW) + } + whenever(context.packageManager).thenReturn(packageManager) + whenever( + packageManager.getPackageInfoAsUser( + anyString(), + eq(PackageManager.GET_PERMISSIONS), + anyInt(), + ) + ) + .thenReturn(packageInfo) + } + + private fun createTaskViewMock(): TaskView { + val taskView: TaskView = mock() + whenever(taskView.type).thenReturn(TaskViewType.SINGLE) + whenever(taskView.context).thenReturn(context) + return taskView + } } |