diff --git a/AndroidManifest-common.xml b/AndroidManifest-common.xml index 12daa5993f..d7de8a7a66 100644 --- a/AndroidManifest-common.xml +++ b/AndroidManifest-common.xml @@ -40,6 +40,7 @@ + diff --git a/quickstep/res/drawable/hotseat_icon.xml b/quickstep/res/drawable/hotseat_icon.xml new file mode 100644 index 0000000000..b849fe90ce --- /dev/null +++ b/quickstep/res/drawable/hotseat_icon.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/quickstep/res/drawable/hotseat_icon_home.xml b/quickstep/res/drawable/hotseat_icon_home.xml new file mode 100644 index 0000000000..d59dd4a88e --- /dev/null +++ b/quickstep/res/drawable/hotseat_icon_home.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/quickstep/res/drawable/hotseat_search_bar.xml b/quickstep/res/drawable/hotseat_search_bar.xml new file mode 100644 index 0000000000..ea332e9462 --- /dev/null +++ b/quickstep/res/drawable/hotseat_search_bar.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/quickstep/res/drawable/top_task_view.xml b/quickstep/res/drawable/top_task_view.xml new file mode 100644 index 0000000000..d2176c3cb4 --- /dev/null +++ b/quickstep/res/drawable/top_task_view.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/quickstep/res/layout-land/gesture_tutorial_mock_hotseat.xml b/quickstep/res/layout-land/gesture_tutorial_mock_hotseat.xml index 1e2e014111..c7e176ac2e 100644 --- a/quickstep/res/layout-land/gesture_tutorial_mock_hotseat.xml +++ b/quickstep/res/layout-land/gesture_tutorial_mock_hotseat.xml @@ -24,54 +24,50 @@ android:paddingStart="56dp" android:paddingEnd="56dp"> - - - - - - - - - - - + app:layout_constraintTop_toTopOf="@id/full_task_view" /> - + app:layout_constraintTop_toBottomOf="@id/top_task_view" /> diff --git a/quickstep/res/layout/gesture_tutorial_mock_hotseat.xml b/quickstep/res/layout/gesture_tutorial_mock_hotseat.xml index 8513dcf3d7..8ee0339e9c 100644 --- a/quickstep/res/layout/gesture_tutorial_mock_hotseat.xml +++ b/quickstep/res/layout/gesture_tutorial_mock_hotseat.xml @@ -8,67 +8,62 @@ android:paddingStart="26dp" android:paddingEnd="26dp"> - - - - - diff --git a/quickstep/res/layout/gesture_tutorial_tablet_mock_hotseat.xml b/quickstep/res/layout/gesture_tutorial_tablet_mock_hotseat.xml index 363f14e481..63c51e8040 100644 --- a/quickstep/res/layout/gesture_tutorial_tablet_mock_hotseat.xml +++ b/quickstep/res/layout/gesture_tutorial_tablet_mock_hotseat.xml @@ -22,84 +22,78 @@ android:paddingStart="@dimen/gesture_tutorial_hotseat_padding_start_end" android:paddingEnd="@dimen/gesture_tutorial_hotseat_padding_start_end"> - - - - - - - + app:layout_constraintTop_toTopOf="@id/full_task_view" /> - + android:layout_centerHorizontal="true" + android:layout_marginBottom="@dimen/gesture_tutorial_taskbar_margin_bottom" /> + android:paddingEnd="26dp"> - - - - - diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java index b5afda388a..df95dc1275 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java +++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java @@ -39,6 +39,7 @@ import com.android.launcher3.Launcher; import com.android.launcher3.LauncherState; import com.android.launcher3.Utilities; import com.android.launcher3.anim.AnimatorPlaybackController; +import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.states.StateAnimationConfig; import com.android.launcher3.taskbar.LauncherTaskbarUIController; import com.android.launcher3.uioverrides.QuickstepLauncher; @@ -62,6 +63,7 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch private static final long TRANSLATION_ANIM_MIN_DURATION_MS = 80; private static final float TRANSLATION_ANIM_VELOCITY_DP_PER_MS = 0.8f; + private final VibratorWrapper mVibratorWrapper; private final RecentsView mRecentsView; private final MotionPauseDetector mMotionPauseDetector; private final float mMotionPauseMinDisplacement; @@ -82,6 +84,7 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch mRecentsView = l.getOverviewPanel(); mMotionPauseDetector = new MotionPauseDetector(l); mMotionPauseMinDisplacement = ViewConfiguration.get(l).getScaledTouchSlop(); + mVibratorWrapper = VibratorWrapper.INSTANCE.get(l.getApplicationContext()); } @Override @@ -188,6 +191,11 @@ public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouch // need to manually set the duration to a reasonable value. animator.setDuration(HINT_STATE.getTransitionDuration(mLauncher, true /* isToState */)); } + if (FeatureFlags.ENABLE_HAPTICS_ALL_APPS.get() && + ((mFromState == NORMAL && mToState == ALL_APPS) + || (mFromState == ALL_APPS && mToState == NORMAL)) && isFling) { + mVibratorWrapper.vibrateForDragBump(); + } } private void onMotionPauseDetected() { diff --git a/quickstep/src/com/android/quickstep/interaction/AnimatedTaskView.java b/quickstep/src/com/android/quickstep/interaction/AnimatedTaskView.java index 53ad138b6a..3ccd683eba 100644 --- a/quickstep/src/com/android/quickstep/interaction/AnimatedTaskView.java +++ b/quickstep/src/com/android/quickstep/interaction/AnimatedTaskView.java @@ -30,7 +30,6 @@ import android.view.ViewOutlineProvider; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.cardview.widget.CardView; import androidx.constraintlayout.widget.ConstraintLayout; import com.android.launcher3.R; @@ -46,8 +45,8 @@ import java.util.ArrayList; public class AnimatedTaskView extends ConstraintLayout { private View mFullTaskView; - private CardView mTopTaskView; - private CardView mBottomTaskView; + private View mTopTaskView; + private View mBottomTaskView; private ViewOutlineProvider mTaskViewOutlineProvider = null; private final Rect mTaskViewAnimatedRect = new Rect(); @@ -185,8 +184,6 @@ public class AnimatedTaskView extends ConstraintLayout { void setFakeTaskViewFillColor(@ColorInt int colorResId) { mFullTaskView.setBackgroundColor(colorResId); - mTopTaskView.setCardBackgroundColor(colorResId); - mBottomTaskView.setCardBackgroundColor(colorResId); } @Override diff --git a/quickstep/src/com/android/quickstep/interaction/HomeGestureTutorialController.java b/quickstep/src/com/android/quickstep/interaction/HomeGestureTutorialController.java index c89d4b61a8..bce639b200 100644 --- a/quickstep/src/com/android/quickstep/interaction/HomeGestureTutorialController.java +++ b/quickstep/src/com/android/quickstep/interaction/HomeGestureTutorialController.java @@ -61,10 +61,10 @@ final class HomeGestureTutorialController extends SwipeUpGestureTutorialControll @Override protected int getMockAppTaskLayoutResId() { - return mTutorialFragment.isLargeScreen() - ? R.layout.gesture_tutorial_tablet_mock_webpage - : ENABLE_NEW_GESTURE_NAV_TUTORIAL.get() - ? R.layout.swipe_up_gesture_tutorial_shape + return ENABLE_NEW_GESTURE_NAV_TUTORIAL.get() + ? R.layout.swipe_up_gesture_tutorial_shape + : mTutorialFragment.isLargeScreen() + ? R.layout.gesture_tutorial_tablet_mock_webpage : R.layout.gesture_tutorial_mock_webpage; } diff --git a/quickstep/src/com/android/quickstep/interaction/TutorialController.java b/quickstep/src/com/android/quickstep/interaction/TutorialController.java index ccdb2660bd..6fcb840349 100644 --- a/quickstep/src/com/android/quickstep/interaction/TutorialController.java +++ b/quickstep/src/com/android/quickstep/interaction/TutorialController.java @@ -154,7 +154,6 @@ abstract class TutorialController implements BackGestureAttemptCallback, mFeedbackTitleView.setText(getIntroductionTitle()); mFeedbackSubtitleView.setText(getIntroductionSubtitle()); - mSkipButton.setVisibility(GONE); } mTitleViewCallback = () -> mFeedbackTitleView.sendAccessibilityEvent( diff --git a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java index 3f7d677970..5cf79ea8e2 100644 --- a/quickstep/src/com/android/quickstep/views/GroupedTaskView.java +++ b/quickstep/src/com/android/quickstep/views/GroupedTaskView.java @@ -355,6 +355,12 @@ public class GroupedTaskView extends TaskView { mSnapshotView2.setSplashAlpha(mTaskThumbnailSplashAlpha); } + @Override + protected void refreshTaskThumbnailSplash() { + super.refreshTaskThumbnailSplash(); + mSnapshotView2.refreshSplashView(); + } + /** * Sets visibility for thumbnails and associated elements (DWB banners). * IconView is unaffected. diff --git a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java index 886fc80a22..c6dc15a9fd 100644 --- a/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java +++ b/quickstep/src/com/android/quickstep/views/LauncherRecentsView.java @@ -92,6 +92,7 @@ public class LauncherRecentsView extends RecentsView + + + + + + + + + + + + + + + diff --git a/src/com/android/launcher3/Workspace.java b/src/com/android/launcher3/Workspace.java index fa54dcfc59..cfb8ca4df5 100644 --- a/src/com/android/launcher3/Workspace.java +++ b/src/com/android/launcher3/Workspace.java @@ -501,7 +501,7 @@ public class Workspace extends PagedView } private boolean isTwoPanelEnabled() { - return mLauncher.mDeviceProfile.isTwoPanels; + return !FOLDABLE_SINGLE_PAGE.get() && mLauncher.mDeviceProfile.isTwoPanels; } @Override @@ -663,8 +663,9 @@ public class Workspace extends PagedView // Inflate the cell layout, but do not add it automatically so that we can get the newly // created CellLayout. + DeviceProfile dp = mLauncher.getDeviceProfile(); CellLayout newScreen; - if (FOLDABLE_SINGLE_PAGE.get() && isTwoPanelEnabled()) { + if (FOLDABLE_SINGLE_PAGE.get() && dp.isTwoPanels) { newScreen = (CellLayout) LayoutInflater.from(getContext()).inflate( R.layout.workspace_screen_foldable, this, false /* attachToRoot */); } else { diff --git a/src/com/android/launcher3/allapps/AllAppsTransitionController.java b/src/com/android/launcher3/allapps/AllAppsTransitionController.java index 4a1c3347c9..b6187244f2 100644 --- a/src/com/android/launcher3/allapps/AllAppsTransitionController.java +++ b/src/com/android/launcher3/allapps/AllAppsTransitionController.java @@ -32,6 +32,7 @@ import static com.android.launcher3.util.SystemUiController.UI_STATE_ALL_APPS; import android.animation.Animator; import android.animation.Animator.AnimatorListener; import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.util.FloatProperty; import android.view.HapticFeedbackConstants; import android.view.View; @@ -47,17 +48,21 @@ import com.android.launcher3.DeviceProfile.OnDeviceProfileChangeListener; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherState; import com.android.launcher3.R; +import com.android.launcher3.Utilities; import com.android.launcher3.anim.AnimatedFloat; import com.android.launcher3.anim.AnimatorListeners; import com.android.launcher3.anim.Interpolators; import com.android.launcher3.anim.PendingAnimation; import com.android.launcher3.anim.PropertySetter; +import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.statemanager.StateManager.StateHandler; import com.android.launcher3.states.StateAnimationConfig; +import com.android.launcher3.touch.AllAppsSwipeController; import com.android.launcher3.util.MultiPropertyFactory; import com.android.launcher3.util.MultiPropertyFactory.MultiProperty; import com.android.launcher3.util.MultiValueAlpha; import com.android.launcher3.util.Themes; +import com.android.launcher3.util.VibratorWrapper; import com.android.launcher3.views.ScrimView; /** @@ -78,6 +83,8 @@ public class AllAppsTransitionController private static final int REVERT_SWIPE_ALL_APPS_TO_HOME_ANIMATION_DURATION_MS = 200; private static final float NAV_BAR_COLOR_FORCE_UPDATE_THRESHOLD = 0.1f; + private static final float SWIPE_DRAG_COMMIT_THRESHOLD = + 1 - AllAppsSwipeController.ALL_APPS_STATE_TRANSITION_MANUAL; public static final FloatProperty ALL_APPS_PROGRESS = new FloatProperty("allAppsProgress") { @@ -181,6 +188,7 @@ public class AllAppsTransitionController private boolean mIsTablet; private boolean mHasScaleEffect; + private final VibratorWrapper mVibratorWrapper; public AllAppsTransitionController(Launcher l) { mLauncher = l; @@ -193,6 +201,7 @@ public class AllAppsTransitionController setShiftRange(dp.allAppsShiftRange); mLauncher.addOnDeviceProfileChangeListener(this); + mVibratorWrapper = VibratorWrapper.INSTANCE.get(mLauncher.getApplicationContext()); } public float getShiftRange() { @@ -311,6 +320,11 @@ public class AllAppsTransitionController /** * Creates an animation which updates the vertical transition progress and updates all the * dependent UI using various animation events + * + * This method also dictates where along the progress the haptics should be played. As the user + * scrolls up from workspace or down from AllApps, a drag haptic is being played until the + * commit point where it plays a commit haptic. Where we play the haptics differs when going + * from workspace -> allApps and vice versa. */ @Override public void setStateWithAnimation(LauncherState toState, @@ -339,6 +353,20 @@ public class AllAppsTransitionController }); } + if(FeatureFlags.ENABLE_HAPTICS_ALL_APPS.get() && config.userControlled + && Utilities.ATLEAST_S) { + if (toState == ALL_APPS) { + builder.addOnFrameListener( + new VibrationAnimatorUpdateListener(this, mVibratorWrapper, + SWIPE_DRAG_COMMIT_THRESHOLD, 1)); + } else { + builder.addOnFrameListener( + new VibrationAnimatorUpdateListener(this, mVibratorWrapper, + 0, SWIPE_DRAG_COMMIT_THRESHOLD)); + } + builder.addEndListener(mVibratorWrapper::cancelVibrate); + } + float targetProgress = toState.getVerticalProgress(mLauncher); if (Float.compare(mProgress, targetProgress) == 0) { setAlphas(toState, config, builder); @@ -356,7 +384,7 @@ public class AllAppsTransitionController setAlphas(toState, config, builder); - if (ALL_APPS.equals(toState) && mLauncher.isInState(NORMAL)) { + if (ALL_APPS.equals(toState) && mLauncher.isInState(NORMAL) && !(Utilities.ATLEAST_S)) { mLauncher.getAppsView().performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING); } @@ -494,4 +522,45 @@ public class AllAppsTransitionController } } } + + /** + * This VibrationAnimatorUpdateListener class takes in four parameters, a controller, start + * threshold, end threshold, and a Vibrator wrapper. We use the progress given by the controller + * as it gives an accurate progress that dictates where the vibrator should vibrate. + * Note: once the user begins a gesture and does the commit haptic, there should not be anymore + * haptics played for that gesture. + */ + private static class VibrationAnimatorUpdateListener implements + ValueAnimator.AnimatorUpdateListener { + private final VibratorWrapper mVibratorWrapper; + private final AllAppsTransitionController mController; + private final float mStartThreshold; + private final float mEndThreshold; + private boolean mHasCommitted; + + VibrationAnimatorUpdateListener(AllAppsTransitionController controller, + VibratorWrapper vibratorWrapper, float startThreshold, + float endThreshold) { + mController = controller; + mVibratorWrapper = vibratorWrapper; + mStartThreshold = startThreshold; + mEndThreshold = endThreshold; + } + + @Override + public void onAnimationUpdate(ValueAnimator animation) { + if (mHasCommitted) { + return; + } + float currentProgress = + AllAppsTransitionController.ALL_APPS_PROGRESS.get(mController); + if (currentProgress > mStartThreshold && currentProgress < mEndThreshold) { + mVibratorWrapper.vibrateForDragTexture(); + } else if (!(currentProgress == 0 || currentProgress == 1)) { + // This check guards against committing at the location of the start of the gesture + mVibratorWrapper.vibrateForDragCommit(); + mHasCommitted = true; + } + } + } } diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java index 7b2fff1967..b5879f9ce1 100644 --- a/src/com/android/launcher3/config/FeatureFlags.java +++ b/src/com/android/launcher3/config/FeatureFlags.java @@ -374,6 +374,8 @@ public final class FeatureFlags { "ENABLE_LAUNCH_FROM_STAGED_APP", true, "Enable the ability to tap a staged app during split select to launch it in full screen" ); + public static final BooleanFlag ENABLE_HAPTICS_ALL_APPS = getDebugFlag( + "ENABLE_HAPTICS_ALL_APPS", false, "Enables haptics opening/closing All apps"); public static final BooleanFlag ENABLE_FORCED_MONO_ICON = getDebugFlag( "ENABLE_FORCED_MONO_ICON", false, diff --git a/src/com/android/launcher3/touch/AllAppsSwipeController.java b/src/com/android/launcher3/touch/AllAppsSwipeController.java index bfd0e1b74b..a53751fcb8 100644 --- a/src/com/android/launcher3/touch/AllAppsSwipeController.java +++ b/src/com/android/launcher3/touch/AllAppsSwipeController.java @@ -129,10 +129,7 @@ public class AllAppsSwipeController extends AbstractStateChangeTouchController { Interpolators.clampToProgress( Interpolators.mapToProgress(EMPHASIZED_DECELERATE, 0.4f, 1f), ALL_APPS_STATE_TRANSITION_ATOMIC, 1f); - public static final Interpolator ALL_APPS_VERTICAL_PROGRESS_MANUAL = - Interpolators.clampToProgress( - Interpolators.mapToProgress(LINEAR, ALL_APPS_STATE_TRANSITION_MANUAL, 1f), - ALL_APPS_STATE_TRANSITION_MANUAL, 1f); + public static final Interpolator ALL_APPS_VERTICAL_PROGRESS_MANUAL = LINEAR; // -------- diff --git a/src/com/android/launcher3/util/ResourceHelper.kt b/src/com/android/launcher3/util/ResourceHelper.kt new file mode 100644 index 0000000000..0ca788840f --- /dev/null +++ b/src/com/android/launcher3/util/ResourceHelper.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 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.launcher3.util + +import android.content.Context +import android.content.res.TypedArray +import android.content.res.XmlResourceParser +import android.util.AttributeSet +import kotlin.IntArray + +/** + * This class is a helper that can be subclassed in tests to provide a way to parse attributes + * correctly. + */ +open class ResourceHelper(private val context: Context, private val specsFileId: Int) { + open fun getXml(): XmlResourceParser { + return context.resources.getXml(specsFileId) + } + + open fun obtainStyledAttributes(attrs: AttributeSet, styleId: IntArray): TypedArray { + return context.obtainStyledAttributes(attrs, styleId) + } +} diff --git a/src/com/android/launcher3/util/VibratorWrapper.java b/src/com/android/launcher3/util/VibratorWrapper.java index 932bcfc292..ceba0db384 100644 --- a/src/com/android/launcher3/util/VibratorWrapper.java +++ b/src/com/android/launcher3/util/VibratorWrapper.java @@ -17,7 +17,6 @@ package com.android.launcher3.util; import static android.os.VibrationEffect.createPredefined; import static android.provider.Settings.System.HAPTIC_FEEDBACK_ENABLED; - import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR; @@ -28,12 +27,17 @@ import android.content.Context; import android.database.ContentObserver; import android.media.AudioAttributes; import android.os.Build; +import android.os.SystemClock; import android.os.VibrationEffect; import android.os.Vibrator; import android.provider.Settings; +import androidx.annotation.Nullable; + import com.android.launcher3.Utilities; -import com.android.launcher3.util.MainThreadInitializedObject; +import com.android.launcher3.anim.PendingAnimation; + +import java.util.function.Consumer; /** * Wrapper around {@link Vibrator} to easily perform haptic feedback where necessary. @@ -52,6 +56,21 @@ public class VibratorWrapper { public static final VibrationEffect EFFECT_CLICK = createPredefined(VibrationEffect.EFFECT_CLICK); + private static final float DRAG_TEXTURE_SCALE = 0.03f; + private static final float DRAG_COMMIT_SCALE = 0.5f; + private static final float DRAG_BUMP_SCALE = 0.4f; + private static final int DRAG_TEXTURE_EFFECT_SIZE = 200; + + @Nullable + private final VibrationEffect mDragEffect; + @Nullable + private final VibrationEffect mCommitEffect; + @Nullable + private final VibrationEffect mBumpEffect; + + private long mLastDragTime; + private final int mThresholdUntilNextDragCallMillis; + /** * Haptic when entering overview. */ @@ -62,7 +81,7 @@ public class VibratorWrapper { private boolean mIsHapticFeedbackEnabled; - public VibratorWrapper(Context context) { + private VibratorWrapper(Context context) { mVibrator = context.getSystemService(Vibrator.class); mHasVibrator = mVibrator.hasVibrator(); if (mHasVibrator) { @@ -75,12 +94,88 @@ public class VibratorWrapper { } }; resolver.registerContentObserver(Settings.System.getUriFor(HAPTIC_FEEDBACK_ENABLED), - false /* notifyForDescendents */, observer); + false /* notifyForDescendants */, observer); } else { mIsHapticFeedbackEnabled = false; } + + if (Utilities.ATLEAST_S && mVibrator.areAllPrimitivesSupported( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK)) { + + // Drag texture, Commit, and Bump should only be used for premium phones. + // Before using these haptics make sure check if the device can use it + VibrationEffect.Composition dragEffect = VibrationEffect.startComposition(); + for (int i = 0; i < DRAG_TEXTURE_EFFECT_SIZE; i++) { + dragEffect.addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, DRAG_TEXTURE_SCALE); + } + mDragEffect = dragEffect.compose(); + mCommitEffect = VibrationEffect.startComposition().addPrimitive( + VibrationEffect.Composition.PRIMITIVE_TICK, DRAG_COMMIT_SCALE).compose(); + mBumpEffect = VibrationEffect.startComposition().addPrimitive( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK, DRAG_BUMP_SCALE).compose(); + int primitiveDuration = mVibrator.getPrimitiveDurations( + VibrationEffect.Composition.PRIMITIVE_LOW_TICK)[0]; + + mThresholdUntilNextDragCallMillis = + DRAG_TEXTURE_EFFECT_SIZE * primitiveDuration + 100; + } else { + mDragEffect = null; + mCommitEffect = null; + mBumpEffect = null; + mThresholdUntilNextDragCallMillis = 0; + } } + /** + * This is called when the user swipes to/from all apps. This is meant to be used in between + * long animation progresses so that it gives a dragging texture effect. For a better + * experience, this should be used in combination with vibrateForDragCommit(). + */ + public void vibrateForDragTexture() { + if (mDragEffect == null) { + return; + } + long currentTime = SystemClock.elapsedRealtime(); + long elapsedTimeSinceDrag = currentTime - mLastDragTime; + if (elapsedTimeSinceDrag >= mThresholdUntilNextDragCallMillis) { + vibrate(mDragEffect); + mLastDragTime = currentTime; + } + } + + /** + * This is used when user reaches the commit threshold when swiping to/from from all apps. + */ + public void vibrateForDragCommit() { + if (mCommitEffect != null) { + vibrate(mCommitEffect); + } + // resetting dragTexture timestamp to be able to play dragTexture again + mLastDragTime = 0; + } + + /** + * The bump haptic is used to be called at the end of a swipe and only if it the gesture is a + * FLING going to/from all apps. Client can just call this method elsewhere just for the + * effect. + */ + public void vibrateForDragBump() { + if (mBumpEffect != null) { + vibrate(mBumpEffect); + } + } + + /** + * This should be used to cancel a haptic in case where the haptic shouldn't be vibrating. For + * example, when no animation is happening but a vibrator happens to be vibrating still. Need + * boolean parameter for {@link PendingAnimation#addEndListener(Consumer)}. + */ + public void cancelVibrate(boolean unused) { + UI_HELPER_EXECUTOR.execute(mVibrator::cancel); + // reset dragTexture timestamp to be able to play dragTexture again whenever cancelled + mLastDragTime = 0; + } private boolean isHapticFeedbackEnabled(ContentResolver resolver) { return Settings.System.getInt(resolver, HAPTIC_FEEDBACK_ENABLED, 0) == 1; } diff --git a/src/com/android/launcher3/workspace/WorkspaceSpecs.kt b/src/com/android/launcher3/workspace/WorkspaceSpecs.kt new file mode 100644 index 0000000000..0f6e1b0af1 --- /dev/null +++ b/src/com/android/launcher3/workspace/WorkspaceSpecs.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2023 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.launcher3.workspace + +import android.content.res.TypedArray +import android.content.res.XmlResourceParser +import android.util.AttributeSet +import android.util.Log +import android.util.TypedValue +import android.util.Xml +import com.android.launcher3.R +import com.android.launcher3.util.ResourceHelper +import java.io.IOException +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException + +private const val TAG = "WorkspaceSpecs" + +class WorkspaceSpecs(resourceHelper: ResourceHelper) { + object XmlTags { + const val WORKSPACE_SPECS = "workspaceSpecs" + + const val WORKSPACE_SPEC = "workspaceSpec" + const val START_PADDING = "startPadding" + const val END_PADDING = "endPadding" + const val GUTTER = "gutter" + const val CELL_SIZE = "cellSize" + } + + val workspaceHeightSpecList = mutableListOf() + val workspaceWidthSpecList = mutableListOf() + + init { + try { + val parser: XmlResourceParser = resourceHelper.getXml() + val depth = parser.depth + var type: Int + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > depth) && type != XmlPullParser.END_DOCUMENT + ) { + if (type == XmlPullParser.START_TAG && XmlTags.WORKSPACE_SPECS == parser.name) { + val displayDepth = parser.depth + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > displayDepth) && type != XmlPullParser.END_DOCUMENT + ) { + if ( + type == XmlPullParser.START_TAG && XmlTags.WORKSPACE_SPEC == parser.name + ) { + val attrs = + resourceHelper.obtainStyledAttributes( + Xml.asAttributeSet(parser), + R.styleable.WorkspaceSpec + ) + val maxAvailableSize = + attrs.getDimensionPixelSize( + R.styleable.WorkspaceSpec_maxAvailableSize, + 0 + ) + val specType = + WorkspaceSpec.SpecType.values()[ + attrs.getInt( + R.styleable.WorkspaceSpec_specType, + WorkspaceSpec.SpecType.HEIGHT.ordinal + ) + ] + attrs.recycle() + + var startPadding: SizeSpec? = null + var endPadding: SizeSpec? = null + var gutter: SizeSpec? = null + var cellSize: SizeSpec? = null + + val limitDepth = parser.depth + while ( + (parser.next().also { type = it } != XmlPullParser.END_TAG || + parser.depth > limitDepth) && type != XmlPullParser.END_DOCUMENT + ) { + val attr: AttributeSet = Xml.asAttributeSet(parser) + if (type == XmlPullParser.START_TAG) { + when (parser.name) { + XmlTags.START_PADDING -> { + startPadding = SizeSpec(resourceHelper, attr) + } + XmlTags.END_PADDING -> { + endPadding = SizeSpec(resourceHelper, attr) + } + XmlTags.GUTTER -> { + gutter = SizeSpec(resourceHelper, attr) + } + XmlTags.CELL_SIZE -> { + cellSize = SizeSpec(resourceHelper, attr) + } + } + } + } + + if ( + startPadding == null || + endPadding == null || + gutter == null || + cellSize == null + ) { + throw IllegalStateException( + "All attributes in workspaceSpec must be defined" + ) + } + + val workspaceSpec = + WorkspaceSpec( + maxAvailableSize, + specType, + startPadding, + endPadding, + gutter, + cellSize + ) + if (workspaceSpec.isValid()) { + if (workspaceSpec.specType == WorkspaceSpec.SpecType.HEIGHT) + workspaceHeightSpecList.add(workspaceSpec) + else workspaceWidthSpecList.add(workspaceSpec) + } else { + throw IllegalStateException("Invalid workspaceSpec found.") + } + } + } + + if (workspaceWidthSpecList.isEmpty() || workspaceHeightSpecList.isEmpty()) { + throw IllegalStateException( + "WorkspaceSpecs is incomplete - " + + "height list size = ${workspaceHeightSpecList.size}; " + + "width list size = ${workspaceWidthSpecList.size}." + ) + } + } + } + parser.close() + } catch (e: Exception) { + when (e) { + is IOException, + is XmlPullParserException -> { + throw RuntimeException("Failure parsing workspaces specs file.", e) + } + else -> throw e + } + } + } +} + +data class WorkspaceSpec( + val maxAvailableSize: Int, + val specType: SpecType, + val startPadding: SizeSpec, + val endPadding: SizeSpec, + val gutter: SizeSpec, + val cellSize: SizeSpec +) { + + enum class SpecType { + HEIGHT, + WIDTH + } + + fun isValid(): Boolean { + if (maxAvailableSize <= 0) { + Log.e(TAG, "WorkspaceSpec#isValid - maxAvailableSize <= 0") + return false + } + + // All specs need to be individually valid + if (!allSpecsAreValid()) { + Log.e(TAG, "WorkspaceSpec#isValid - !allSpecsAreValid()") + return false + } + + return true + } + + private fun allSpecsAreValid(): Boolean = + startPadding.isValid() && endPadding.isValid() && gutter.isValid() && cellSize.isValid() +} + +class SizeSpec(resourceHelper: ResourceHelper, attrs: AttributeSet) { + val fixedSize: Float + val ofAvailableSpace: Float + val ofRemainderSpace: Float + + init { + val styledAttrs = resourceHelper.obtainStyledAttributes(attrs, R.styleable.SpecSize) + + fixedSize = getValue(styledAttrs, R.styleable.SpecSize_fixedSize) + ofAvailableSpace = getValue(styledAttrs, R.styleable.SpecSize_ofAvailableSpace) + ofRemainderSpace = getValue(styledAttrs, R.styleable.SpecSize_ofRemainderSpace) + + styledAttrs.recycle() + } + + private fun getValue(a: TypedArray, index: Int): Float { + if (a.getType(index) == TypedValue.TYPE_DIMENSION) { + return a.getDimensionPixelSize(index, 0).toFloat() + } else if (a.getType(index) == TypedValue.TYPE_FLOAT) { + return a.getFloat(index, 0f) + } + return 0f + } + + fun isValid(): Boolean { + // All attributes are empty + if (fixedSize <= 0f && ofAvailableSpace <= 0f && ofRemainderSpace <= 0f) { + Log.e(TAG, "SizeSpec#isValid - all attributes are empty") + return false + } + + // More than one attribute is filled + val attrCount = + (if (fixedSize > 0) 1 else 0) + + (if (ofAvailableSpace > 0) 1 else 0) + + (if (ofRemainderSpace > 0) 1 else 0) + if (attrCount > 1) { + Log.e(TAG, "SizeSpec#isValid - more than one attribute is filled") + return false + } + + // Values should be between 0 and 1 + if (ofAvailableSpace !in 0f..1f || ofRemainderSpace !in 0f..1f) { + Log.e(TAG, "SizeSpec#isValid - values should be between 0 and 1") + return false + } + + return true + } + + override fun toString(): String { + return "SizeSpec(fixedSize=$fixedSize, ofAvailableSpace=$ofAvailableSpace, " + + "ofRemainderSpace=$ofRemainderSpace)" + } +} diff --git a/tests/res/values/attrs.xml b/tests/res/values/attrs.xml new file mode 100644 index 0000000000..2310d9ef66 --- /dev/null +++ b/tests/res/values/attrs.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/invalid_workspace_file_case_1.xml b/tests/res/xml/invalid_workspace_file_case_1.xml new file mode 100644 index 0000000000..0be704bd8f --- /dev/null +++ b/tests/res/xml/invalid_workspace_file_case_1.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/invalid_workspace_file_case_2.xml b/tests/res/xml/invalid_workspace_file_case_2.xml new file mode 100644 index 0000000000..5a37d97ba2 --- /dev/null +++ b/tests/res/xml/invalid_workspace_file_case_2.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/invalid_workspace_file_case_3.xml b/tests/res/xml/invalid_workspace_file_case_3.xml new file mode 100644 index 0000000000..3e68edb15d --- /dev/null +++ b/tests/res/xml/invalid_workspace_file_case_3.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/res/xml/valid_workspace_file.xml b/tests/res/xml/valid_workspace_file.xml new file mode 100644 index 0000000000..91a3e48d21 --- /dev/null +++ b/tests/res/xml/valid_workspace_file.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt new file mode 100644 index 0000000000..dcc669bc38 --- /dev/null +++ b/tests/src/com/android/launcher3/AbstractDeviceProfileTest.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2023 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.launcher3 + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Point +import android.graphics.Rect +import android.util.DisplayMetrics +import android.view.Surface +import androidx.test.core.app.ApplicationProvider +import com.android.launcher3.util.DisplayController +import com.android.launcher3.util.NavigationMode +import com.android.launcher3.util.WindowBounds +import com.android.launcher3.util.window.CachedDisplayInfo +import com.android.launcher3.util.window.WindowManagerProxy +import kotlin.math.max +import kotlin.math.min +import org.junit.After +import org.junit.Before +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` as whenever + +/** + * This is an abstract class for DeviceProfile tests that create an InvariantDeviceProfile based on + * a real device spec. + * + * For an implementation that mocks InvariantDeviceProfile, use [FakeInvariantDeviceProfileTest] + */ +abstract class AbstractDeviceProfileTest { + protected var context: Context? = null + protected open val runningContext: Context = ApplicationProvider.getApplicationContext() + private var displayController: DisplayController = mock(DisplayController::class.java) + private var windowManagerProxy: WindowManagerProxy = mock(WindowManagerProxy::class.java) + private lateinit var originalDisplayController: DisplayController + private lateinit var originalWindowManagerProxy: WindowManagerProxy + + @Before + fun setUp() { + val appContext: Context = ApplicationProvider.getApplicationContext() + originalWindowManagerProxy = WindowManagerProxy.INSTANCE.get(appContext) + originalDisplayController = DisplayController.INSTANCE.get(appContext) + WindowManagerProxy.INSTANCE.initializeForTesting(windowManagerProxy) + DisplayController.INSTANCE.initializeForTesting(displayController) + } + + @After + fun tearDown() { + WindowManagerProxy.INSTANCE.initializeForTesting(originalWindowManagerProxy) + DisplayController.INSTANCE.initializeForTesting(originalDisplayController) + } + + class DeviceSpec( + val naturalSize: Pair, + val densityDpi: Int, + val statusBarNaturalPx: Int, + val statusBarRotatedPx: Int, + val gesturePx: Int, + val cutoutPx: Int + ) + + open val deviceSpecs = + mapOf( + "phone" to + DeviceSpec( + Pair(1080, 2400), + densityDpi = 420, + statusBarNaturalPx = 118, + statusBarRotatedPx = 74, + gesturePx = 63, + cutoutPx = 118 + ), + "tablet" to + DeviceSpec( + Pair(2560, 1600), + densityDpi = 320, + statusBarNaturalPx = 104, + statusBarRotatedPx = 104, + gesturePx = 0, + cutoutPx = 0 + ), + ) + + protected fun initializeVarsForPhone( + deviceSpec: DeviceSpec, + isGestureMode: Boolean = true, + isVerticalBar: Boolean = false + ) { + val (naturalX, naturalY) = deviceSpec.naturalSize + val windowsBounds = phoneWindowsBounds(deviceSpec, isGestureMode, naturalX, naturalY) + val displayInfo = + CachedDisplayInfo(Point(naturalX, naturalY), Surface.ROTATION_0, Rect(0, 0, 0, 0)) + val perDisplayBoundsCache = mapOf(displayInfo to windowsBounds) + + initializeCommonVars( + perDisplayBoundsCache, + displayInfo, + rotation = if (isVerticalBar) Surface.ROTATION_90 else Surface.ROTATION_0, + isGestureMode, + densityDpi = deviceSpec.densityDpi + ) + } + + protected fun initializeVarsForTablet( + deviceSpec: DeviceSpec, + isLandscape: Boolean = false, + isGestureMode: Boolean = true + ) { + val (naturalX, naturalY) = deviceSpec.naturalSize + val windowsBounds = tabletWindowsBounds(deviceSpec, naturalX, naturalY) + val displayInfo = + CachedDisplayInfo(Point(naturalX, naturalY), Surface.ROTATION_0, Rect(0, 0, 0, 0)) + val perDisplayBoundsCache = mapOf(displayInfo to windowsBounds) + + initializeCommonVars( + perDisplayBoundsCache, + displayInfo, + rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90, + isGestureMode, + densityDpi = deviceSpec.densityDpi + ) + } + + protected fun initializeVarsForTwoPanel( + deviceTabletSpec: DeviceSpec, + deviceSpec: DeviceSpec, + isLandscape: Boolean = false, + isGestureMode: Boolean = true + ) { + val (tabletNaturalX, tabletNaturalY) = deviceTabletSpec.naturalSize + val tabletWindowsBounds = + tabletWindowsBounds(deviceTabletSpec, tabletNaturalX, tabletNaturalY) + val tabletDisplayInfo = + CachedDisplayInfo( + Point(tabletNaturalX, tabletNaturalY), + Surface.ROTATION_0, + Rect(0, 0, 0, 0) + ) + + val (phoneNaturalX, phoneNaturalY) = deviceSpec.naturalSize + val phoneWindowsBounds = + phoneWindowsBounds(deviceSpec, isGestureMode, phoneNaturalX, phoneNaturalY) + val phoneDisplayInfo = + CachedDisplayInfo( + Point(phoneNaturalX, phoneNaturalY), + Surface.ROTATION_0, + Rect(0, 0, 0, 0) + ) + + val perDisplayBoundsCache = + mapOf(tabletDisplayInfo to tabletWindowsBounds, phoneDisplayInfo to phoneWindowsBounds) + + initializeCommonVars( + perDisplayBoundsCache, + tabletDisplayInfo, + rotation = if (isLandscape) Surface.ROTATION_0 else Surface.ROTATION_90, + isGestureMode, + densityDpi = deviceTabletSpec.densityDpi + ) + } + + private fun phoneWindowsBounds( + deviceSpec: DeviceSpec, + isGestureMode: Boolean, + naturalX: Int, + naturalY: Int + ): Array { + val buttonsNavHeight = Utilities.dpToPx(48f, deviceSpec.densityDpi) + + val rotation0Insets = + Rect( + 0, + max(deviceSpec.statusBarNaturalPx, deviceSpec.cutoutPx), + 0, + if (isGestureMode) deviceSpec.gesturePx else buttonsNavHeight + ) + val rotation90Insets = + Rect( + deviceSpec.cutoutPx, + deviceSpec.statusBarRotatedPx, + if (isGestureMode) 0 else buttonsNavHeight, + if (isGestureMode) deviceSpec.gesturePx else 0 + ) + val rotation180Insets = + Rect( + 0, + deviceSpec.statusBarNaturalPx, + 0, + max( + if (isGestureMode) deviceSpec.gesturePx else buttonsNavHeight, + deviceSpec.cutoutPx + ) + ) + val rotation270Insets = + Rect( + if (isGestureMode) 0 else buttonsNavHeight, + deviceSpec.statusBarRotatedPx, + deviceSpec.cutoutPx, + if (isGestureMode) deviceSpec.gesturePx else 0 + ) + + return arrayOf( + WindowBounds(Rect(0, 0, naturalX, naturalY), rotation0Insets, Surface.ROTATION_0), + WindowBounds(Rect(0, 0, naturalY, naturalX), rotation90Insets, Surface.ROTATION_90), + WindowBounds(Rect(0, 0, naturalX, naturalY), rotation180Insets, Surface.ROTATION_180), + WindowBounds(Rect(0, 0, naturalY, naturalX), rotation270Insets, Surface.ROTATION_270) + ) + } + + private fun tabletWindowsBounds( + deviceSpec: DeviceSpec, + naturalX: Int, + naturalY: Int + ): Array { + val naturalInsets = Rect(0, deviceSpec.statusBarNaturalPx, 0, 0) + val rotatedInsets = Rect(0, deviceSpec.statusBarRotatedPx, 0, 0) + + return arrayOf( + WindowBounds(Rect(0, 0, naturalX, naturalY), naturalInsets, Surface.ROTATION_0), + WindowBounds(Rect(0, 0, naturalY, naturalX), rotatedInsets, Surface.ROTATION_90), + WindowBounds(Rect(0, 0, naturalX, naturalY), naturalInsets, Surface.ROTATION_180), + WindowBounds(Rect(0, 0, naturalY, naturalX), rotatedInsets, Surface.ROTATION_270) + ) + } + + private fun initializeCommonVars( + perDisplayBoundsCache: Map>, + displayInfo: CachedDisplayInfo, + rotation: Int, + isGestureMode: Boolean = true, + densityDpi: Int + ) { + val windowsBounds = perDisplayBoundsCache[displayInfo]!! + val realBounds = windowsBounds[rotation] + whenever(windowManagerProxy.getDisplayInfo(ArgumentMatchers.any())).thenReturn(displayInfo) + whenever(windowManagerProxy.getRealBounds(ArgumentMatchers.any(), ArgumentMatchers.any())) + .thenReturn(realBounds) + whenever(windowManagerProxy.getRotation(ArgumentMatchers.any())).thenReturn(rotation) + whenever(windowManagerProxy.getNavigationMode(ArgumentMatchers.any())) + .thenReturn( + if (isGestureMode) NavigationMode.NO_BUTTON else NavigationMode.THREE_BUTTONS + ) + + val density = densityDpi / DisplayMetrics.DENSITY_DEFAULT.toFloat() + val config = + Configuration(runningContext.resources.configuration).apply { + this.densityDpi = densityDpi + screenWidthDp = (realBounds.bounds.width() / density).toInt() + screenHeightDp = (realBounds.bounds.height() / density).toInt() + smallestScreenWidthDp = min(screenWidthDp, screenHeightDp) + } + context = runningContext.createConfigurationContext(config) + + val info = DisplayController.Info(context, windowManagerProxy, perDisplayBoundsCache) + whenever(displayController.info).thenReturn(info) + whenever(displayController.isTransientTaskbar).thenReturn(isGestureMode) + } +} diff --git a/tests/src/com/android/launcher3/DeviceProfileBaseTest.kt b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt similarity index 97% rename from tests/src/com/android/launcher3/DeviceProfileBaseTest.kt rename to tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt index daf4608df7..a5f33c0338 100644 --- a/tests/src/com/android/launcher3/DeviceProfileBaseTest.kt +++ b/tests/src/com/android/launcher3/FakeInvariantDeviceProfileTest.kt @@ -31,7 +31,13 @@ import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.mock import org.mockito.Mockito.`when` as whenever -abstract class DeviceProfileBaseTest { +/** + * This is an abstract class for DeviceProfile tests that don't need the real Context and mock an + * InvariantDeviceProfile instead of creating one based on real values. + * + * For an implementation that creates InvariantDeviceProfile, use [AbstractDeviceProfileTest] + */ +abstract class FakeInvariantDeviceProfileTest { protected var context: Context? = null protected var inv: InvariantDeviceProfile? = null diff --git a/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt b/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt index 951f5f86fa..2a27487e3b 100644 --- a/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt +++ b/tests/src/com/android/launcher3/nonquickstep/HotseatWidthCalculationTest.kt @@ -18,7 +18,7 @@ package com.android.launcher3.nonquickstep import android.graphics.Rect import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest -import com.android.launcher3.DeviceProfileBaseTest +import com.android.launcher3.FakeInvariantDeviceProfileTest import com.android.launcher3.util.WindowBounds import com.google.common.truth.Truth.assertThat import org.junit.Test @@ -26,7 +26,7 @@ import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) -class HotseatWidthCalculationTest : DeviceProfileBaseTest() { +class HotseatWidthCalculationTest : FakeInvariantDeviceProfileTest() { /** * This is a case when after setting the hotseat, the space needs to be recalculated but it diff --git a/tests/src/com/android/launcher3/util/TestResourceHelper.kt b/tests/src/com/android/launcher3/util/TestResourceHelper.kt new file mode 100644 index 0000000000..fb03fe1b53 --- /dev/null +++ b/tests/src/com/android/launcher3/util/TestResourceHelper.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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.launcher3.util + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import com.android.launcher3.R +import com.android.launcher3.tests.R as TestR +import kotlin.IntArray + +class TestResourceHelper(private val context: Context, private val specsFileId: Int) : + ResourceHelper(context, specsFileId) { + override fun obtainStyledAttributes(attrs: AttributeSet, styleId: IntArray): TypedArray { + var clone = styleId.clone() + if (styleId == R.styleable.SpecSize) clone = TestR.styleable.SpecSize + else if (styleId == R.styleable.WorkspaceSpec) clone = TestR.styleable.WorkspaceSpec + return context.obtainStyledAttributes(attrs, clone) + } +} diff --git a/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt b/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt new file mode 100644 index 0000000000..0fd8a5460e --- /dev/null +++ b/tests/src/com/android/launcher3/workspace/WorkspaceSpecsTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2023 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.launcher3.workspace + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.AbstractDeviceProfileTest +import com.android.launcher3.tests.R as TestR +import com.android.launcher3.util.TestResourceHelper +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class WorkspaceSpecsTest : AbstractDeviceProfileTest() { + override val runningContext: Context = InstrumentationRegistry.getInstrumentation().context + + @Before + fun setup() { + initializeVarsForPhone(deviceSpecs["phone"]!!) + } + + @Test + fun parseValidFile() { + val workspaceSpecs = + WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.valid_workspace_file)) + assertThat(workspaceSpecs.workspaceHeightSpecList.size).isEqualTo(2) + assertThat(workspaceSpecs.workspaceHeightSpecList[0].toString()) + .isEqualTo( + "WorkspaceSpec(" + + "maxAvailableSize=1701, " + + "specType=HEIGHT, " + + "startPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0125, " + + "ofRemainderSpace=0.0), " + + "endPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.05, " + + "ofRemainderSpace=0.0), " + + "gutter=SizeSpec(fixedSize=42.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0), " + + "cellSize=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.2)" + + ")" + ) + assertThat(workspaceSpecs.workspaceHeightSpecList[1].toString()) + .isEqualTo( + "WorkspaceSpec(" + + "maxAvailableSize=26247, " + + "specType=HEIGHT, " + + "startPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0306, " + + "ofRemainderSpace=0.0), " + + "endPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.068, " + + "ofRemainderSpace=0.0), " + + "gutter=SizeSpec(fixedSize=42.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0), " + + "cellSize=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.2)" + + ")" + ) + assertThat(workspaceSpecs.workspaceWidthSpecList.size).isEqualTo(1) + assertThat(workspaceSpecs.workspaceWidthSpecList[0].toString()) + .isEqualTo( + "WorkspaceSpec(" + + "maxAvailableSize=26247, " + + "specType=WIDTH, " + + "startPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.21436226), " + + "endPadding=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.21436226), " + + "gutter=SizeSpec(fixedSize=0.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.11425509), " + + "cellSize=SizeSpec(fixedSize=315.0, " + + "ofAvailableSpace=0.0, " + + "ofRemainderSpace=0.0)" + + ")" + ) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_missingTag_throwsError() { + WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_1)) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_moreThanOneValuePerTag_throwsError() { + WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_2)) + } + + @Test(expected = IllegalStateException::class) + fun parseInvalidFile_valueBiggerThan1_throwsError() { + WorkspaceSpecs(TestResourceHelper(context!!, TestR.xml.invalid_workspace_file_case_3)) + } +}