diff options
6 files changed, 364 insertions, 7 deletions
diff --git a/core/java/android/view/ImeBackAnimationController.java b/core/java/android/view/ImeBackAnimationController.java index e14ddd6ed75b..1afedc185c85 100644 --- a/core/java/android/view/ImeBackAnimationController.java +++ b/core/java/android/view/ImeBackAnimationController.java @@ -63,8 +63,8 @@ public class ImeBackAnimationController implements OnBackAnimationCallback { private boolean mIsPreCommitAnimationInProgress = false; private int mStartRootScrollY = 0; - public ImeBackAnimationController(ViewRootImpl viewRoot) { - mInsetsController = viewRoot.getInsetsController(); + public ImeBackAnimationController(ViewRootImpl viewRoot, InsetsController insetsController) { + mInsetsController = insetsController; mViewRoot = viewRoot; } diff --git a/core/java/android/view/InsetsController.java b/core/java/android/view/InsetsController.java index 6c9001163459..edf850cdff78 100644 --- a/core/java/android/view/InsetsController.java +++ b/core/java/android/view/InsetsController.java @@ -1028,7 +1028,8 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation reportRequestedVisibleTypes(); } - void setPredictiveBackImeHideAnimInProgress(boolean isInProgress) { + @VisibleForTesting(visibility = PACKAGE) + public void setPredictiveBackImeHideAnimInProgress(boolean isInProgress) { mIsPredictiveBackImeHideAnimInProgress = isInProgress; } @@ -1231,7 +1232,8 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation false /* fromPredictiveBack */); } - void controlWindowInsetsAnimation(@InsetsType int types, + @VisibleForTesting(visibility = PACKAGE) + public void controlWindowInsetsAnimation(@InsetsType int types, @Nullable CancellationSignal cancellationSignal, WindowInsetsAnimationControlListener listener, boolean fromIme, long durationMs, @Nullable Interpolator interpolator, @@ -1983,7 +1985,8 @@ public class InsetsController implements WindowInsetsController, InsetsAnimation } } - Host getHost() { + @VisibleForTesting(visibility = PACKAGE) + public Host getHost() { return mHost; } } diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java index 00840cc9d047..a41b938829c5 100644 --- a/core/java/android/view/ViewRootImpl.java +++ b/core/java/android/view/ViewRootImpl.java @@ -1205,7 +1205,7 @@ public final class ViewRootImpl implements ViewParent, // TODO(b/222696368): remove getSfInstance usage and use vsyncId for transactions mChoreographer = Choreographer.getInstance(); mInsetsController = new InsetsController(new ViewRootInsetsControllerHost(this)); - mImeBackAnimationController = new ImeBackAnimationController(this); + mImeBackAnimationController = new ImeBackAnimationController(this, mInsetsController); mHandwritingInitiator = new HandwritingInitiator( mViewConfiguration, mContext.getSystemService(InputMethodManager.class)); @@ -5881,13 +5881,19 @@ public final class ViewRootImpl implements ViewParent, return handled; } - void setScrollY(int scrollY) { + @VisibleForTesting(visibility = PACKAGE) + public void setScrollY(int scrollY) { if (mScroller != null) { mScroller.abortAnimation(); } mScrollY = scrollY; } + @VisibleForTesting + public int getScrollY() { + return mScrollY; + } + /** * @hide */ diff --git a/core/tests/coretests/src/android/view/ImeBackAnimationControllerTest.java b/core/tests/coretests/src/android/view/ImeBackAnimationControllerTest.java new file mode 100644 index 000000000000..c00ebe487620 --- /dev/null +++ b/core/tests/coretests/src/android/view/ImeBackAnimationControllerTest.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2024 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 android.view; + +import static android.view.WindowInsets.Type.ime; +import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN; +import static android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; +import static android.window.BackEvent.EDGE_LEFT; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +import android.content.Context; +import android.graphics.Insets; +import android.platform.test.annotations.Presubmit; +import android.view.animation.BackGestureInterpolator; +import android.view.animation.Interpolator; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; +import android.window.BackEvent; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +/** + * Tests for {@link ImeBackAnimationController}. + * + * <p>Build/Install/Run: + * atest FrameworksCoreTests:ImeBackAnimationControllerTest + * + * <p>This test class is a part of Window Manager Service tests and specified in + * {@link com.android.server.wm.test.filters.FrameworksTestsFilter}. + */ +@Presubmit +@RunWith(AndroidJUnit4.class) +public class ImeBackAnimationControllerTest { + + private static final float PEEK_FRACTION = 0.1f; + private static final Interpolator BACK_GESTURE = new BackGestureInterpolator(); + private static final int IME_HEIGHT = 200; + private static final Insets IME_INSETS = Insets.of(0, 0, 0, IME_HEIGHT); + + @Mock + private InsetsController mInsetsController; + @Mock + private WindowInsetsAnimationController mWindowInsetsAnimationController; + @Mock + private ViewRootInsetsControllerHost mViewRootInsetsControllerHost; + + private ViewRootImpl mViewRoot; + private ImeBackAnimationController mBackAnimationController; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + InputMethodManager inputMethodManager = context.getSystemService( + InputMethodManager.class); + // cannot mock ViewRootImpl since it's final. + mViewRoot = new ViewRootImpl(context, context.getDisplayNoVerify()); + try { + mViewRoot.setView(new TextView(context), new WindowManager.LayoutParams(), null); + } catch (WindowManager.BadTokenException e) { + // activity isn't running, we will ignore BadTokenException. + } + mBackAnimationController = new ImeBackAnimationController(mViewRoot, mInsetsController); + + when(mWindowInsetsAnimationController.getHiddenStateInsets()).thenReturn(Insets.NONE); + when(mWindowInsetsAnimationController.getShownStateInsets()).thenReturn(IME_INSETS); + when(mWindowInsetsAnimationController.getCurrentInsets()).thenReturn(IME_INSETS); + when(mInsetsController.getHost()).thenReturn(mViewRootInsetsControllerHost); + when(mViewRootInsetsControllerHost.getInputMethodManager()).thenReturn( + inputMethodManager); + }); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + @Test + public void testAdjustResizeWithAppWindowInsetsListenerPlaysAnim() { + // setup ViewRoot with InsetsAnimationCallback and softInputMode=adjustResize + mViewRoot.getView() + .setWindowInsetsAnimationCallback(mock(WindowInsetsAnimation.Callback.class)); + mViewRoot.mWindowAttributes.softInputMode = SOFT_INPUT_ADJUST_RESIZE; + // start back gesture + mBackAnimationController.onBackStarted(new BackEvent(0f, 0f, 0f, EDGE_LEFT)); + // verify that ImeBackAnimationController takes control over IME insets + verify(mInsetsController, times(1)).controlWindowInsetsAnimation(anyInt(), any(), any(), + anyBoolean(), anyLong(), any(), anyInt(), anyBoolean()); + } + + @Test + public void testAdjustResizeWithoutAppWindowInsetsListenerNotPlayingAnim() { + // setup ViewRoot with softInputMode=adjustResize + mViewRoot.mWindowAttributes.softInputMode = SOFT_INPUT_ADJUST_RESIZE; + // start back gesture + mBackAnimationController.onBackStarted(new BackEvent(0f, 0f, 0f, EDGE_LEFT)); + // progress back gesture + mBackAnimationController.onBackProgressed(new BackEvent(100f, 0f, 0.5f, EDGE_LEFT)); + // commit back gesture + mBackAnimationController.onBackInvoked(); + // verify that InsetsController#hide is called + verify(mInsetsController, times(1)).hide(ime()); + // verify that ImeBackAnimationController does not take control over IME insets + verify(mInsetsController, never()).controlWindowInsetsAnimation(anyInt(), any(), any(), + anyBoolean(), anyLong(), any(), anyInt(), anyBoolean()); + } + + @Test + public void testAdjustPanScrollsViewRoot() { + // simulate view root being panned upwards by 50px + int appPan = -50; + mViewRoot.setScrollY(appPan); + // setup ViewRoot with softInputMode=adjustPan + mViewRoot.mWindowAttributes.softInputMode = SOFT_INPUT_ADJUST_PAN; + + // start back gesture + WindowInsetsAnimationControlListener animationControlListener = startBackGesture(); + // simulate ImeBackAnimationController receiving control + animationControlListener.onReady(mWindowInsetsAnimationController, ime()); + + // progress back gesture + float progress = 0.5f; + mBackAnimationController.onBackProgressed(new BackEvent(100f, 0f, progress, EDGE_LEFT)); + + // verify that view root is scrolled by expected amount + float interpolatedProgress = BACK_GESTURE.getInterpolation(progress); + int expectedViewRootScroll = + (int) (appPan * (1 - interpolatedProgress * PEEK_FRACTION)); + assertEquals(mViewRoot.getScrollY(), expectedViewRootScroll); + } + + @Test + public void testNewGestureAfterCancelSeamlessTakeover() { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + // start back gesture + WindowInsetsAnimationControlListener animationControlListener = startBackGesture(); + // simulate ImeBackAnimationController receiving control + animationControlListener.onReady(mWindowInsetsAnimationController, ime()); + // verify initial animation insets are set + verify(mWindowInsetsAnimationController, times(1)).setInsetsAndAlpha( + eq(Insets.of(0, 0, 0, IME_HEIGHT)), eq(1f), anyFloat()); + + // progress back gesture + mBackAnimationController.onBackProgressed(new BackEvent(100f, 0f, 0.5f, EDGE_LEFT)); + + // cancel back gesture + mBackAnimationController.onBackCancelled(); + // verify that InsetsController does not notified of a hide-anim (because the gesture + // was cancelled) + verify(mInsetsController, never()).setPredictiveBackImeHideAnimInProgress(eq(true)); + + Mockito.clearInvocations(mWindowInsetsAnimationController); + // restart back gesture + mBackAnimationController.onBackStarted(new BackEvent(0f, 0f, 0f, EDGE_LEFT)); + // verify that animation controller is reused and initial insets are set immediately + verify(mWindowInsetsAnimationController, times(1)).setInsetsAndAlpha( + eq(Insets.of(0, 0, 0, IME_HEIGHT)), eq(1f), anyFloat()); + }); + } + + @Test + public void testImeInsetsManipulationCurve() { + // start back gesture + WindowInsetsAnimationControlListener animationControlListener = startBackGesture(); + // simulate ImeBackAnimationController receiving control + animationControlListener.onReady(mWindowInsetsAnimationController, ime()); + // verify initial animation insets are set + verify(mWindowInsetsAnimationController, times(1)).setInsetsAndAlpha( + eq(Insets.of(0, 0, 0, IME_HEIGHT)), eq(1f), anyFloat()); + + Mockito.clearInvocations(mWindowInsetsAnimationController); + // progress back gesture + float progress = 0.5f; + mBackAnimationController.onBackProgressed(new BackEvent(100f, 0f, progress, EDGE_LEFT)); + // verify correct ime insets manipulation + float interpolatedProgress = BACK_GESTURE.getInterpolation(progress); + int expectedInset = + (int) (IME_HEIGHT - interpolatedProgress * PEEK_FRACTION * IME_HEIGHT); + verify(mWindowInsetsAnimationController, times(1)).setInsetsAndAlpha( + eq(Insets.of(0, 0, 0, expectedInset)), eq(1f), anyFloat()); + } + + @Test + public void testOnReadyAfterGestureFinished() { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + // start back gesture + WindowInsetsAnimationControlListener animationControlListener = startBackGesture(); + + // progress back gesture + mBackAnimationController.onBackProgressed(new BackEvent(100f, 0f, 0.5f, EDGE_LEFT)); + + // commit back gesture + mBackAnimationController.onBackInvoked(); + + // verify setInsetsAndAlpha never called due onReady delayed + verify(mWindowInsetsAnimationController, never()).setInsetsAndAlpha(any(), anyInt(), + anyFloat()); + verify(mInsetsController, never()).setPredictiveBackImeHideAnimInProgress(eq(true)); + + // simulate ImeBackAnimationController receiving control + animationControlListener.onReady(mWindowInsetsAnimationController, ime()); + + // verify setInsetsAndAlpha immediately called + verify(mWindowInsetsAnimationController, times(1)).setInsetsAndAlpha( + eq(Insets.of(0, 0, 0, IME_HEIGHT)), eq(1f), anyFloat()); + // verify post-commit hide anim has started + verify(mInsetsController, times(1)).setPredictiveBackImeHideAnimInProgress(eq(true)); + }); + } + + private WindowInsetsAnimationControlListener startBackGesture() { + // start back gesture + mBackAnimationController.onBackStarted(new BackEvent(0f, 0f, 0f, EDGE_LEFT)); + + // verify controlWindowInsetsAnimation is called and capture animationControlListener + ArgumentCaptor<WindowInsetsAnimationControlListener> animationControlListener = + ArgumentCaptor.forClass(WindowInsetsAnimationControlListener.class); + verify(mInsetsController, times(1)).controlWindowInsetsAnimation(anyInt(), any(), + animationControlListener.capture(), anyBoolean(), anyLong(), any(), anyInt(), + anyBoolean()); + + return animationControlListener.getValue(); + } +} diff --git a/core/tests/coretests/src/android/view/InsetsControllerTest.java b/core/tests/coretests/src/android/view/InsetsControllerTest.java index 4fb85c1fa7ff..f05390dff5cd 100644 --- a/core/tests/coretests/src/android/view/InsetsControllerTest.java +++ b/core/tests/coretests/src/android/view/InsetsControllerTest.java @@ -21,6 +21,7 @@ import static android.view.InsetsController.ANIMATION_TYPE_HIDE; import static android.view.InsetsController.ANIMATION_TYPE_NONE; import static android.view.InsetsController.ANIMATION_TYPE_RESIZE; import static android.view.InsetsController.ANIMATION_TYPE_SHOW; +import static android.view.InsetsController.ANIMATION_TYPE_USER; import static android.view.InsetsSource.FLAG_ANIMATE_RESIZING; import static android.view.InsetsSource.ID_IME; import static android.view.InsetsSourceConsumer.ShowResult.IME_SHOW_DELAYED; @@ -925,6 +926,95 @@ public class InsetsControllerTest { }); } + @Test + public void testImeRequestedVisibleDuringPredictiveBackAnim() { + prepareControls(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + // show ime as initial state + mController.show(ime(), true /* fromIme */, ImeTracker.Token.empty()); + mController.cancelExistingAnimations(); // fast forward show animation + assertTrue(mController.getState().peekSource(ID_IME).isVisible()); + + // start control request (for predictive back animation) + WindowInsetsAnimationControlListener listener = + mock(WindowInsetsAnimationControlListener.class); + mController.controlWindowInsetsAnimation(ime(), /*cancellationSignal*/ null, + listener, /*fromIme*/ false, /*duration*/ -1, /*interpolator*/ null, + ANIMATION_TYPE_USER, /*fromPredictiveBack*/ true); + + // Verify that onReady is called (after next predraw) + mViewRoot.getView().getViewTreeObserver().dispatchOnPreDraw(); + verify(listener).onReady(notNull(), eq(ime())); + + // verify that insets are requested visible during animation + assertTrue(isRequestedVisible(mController, ime())); + }); + } + + @Test + public void testImeShowRequestCancelsPredictiveBackPostCommitAnim() { + prepareControls(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + // show ime as initial state + mController.show(ime(), true /* fromIme */, ImeTracker.Token.empty()); + mController.cancelExistingAnimations(); // fast forward show animation + mViewRoot.getView().getViewTreeObserver().dispatchOnPreDraw(); + assertTrue(mController.getState().peekSource(ID_IME).isVisible()); + + // start control request (for predictive back animation) + WindowInsetsAnimationControlListener listener = + mock(WindowInsetsAnimationControlListener.class); + mController.controlWindowInsetsAnimation(ime(), /*cancellationSignal*/ null, + listener, /*fromIme*/ false, /*duration*/ -1, /*interpolator*/ null, + ANIMATION_TYPE_USER, /*fromPredictiveBack*/ true); + + // verify that controller + // has ANIMATION_TYPE_USER set for ime() + assertEquals(ANIMATION_TYPE_USER, mController.getAnimationType(ime())); + + // verify show request is ignored during pre commit phase of predictive back anim + mController.show(ime(), true /* fromIme */, null /* statsToken */); + assertEquals(ANIMATION_TYPE_USER, mController.getAnimationType(ime())); + + // verify show request is applied during post commit phase of predictive back anim + mController.setPredictiveBackImeHideAnimInProgress(true); + mController.show(ime(), true /* fromIme */, null /* statsToken */); + assertEquals(ANIMATION_TYPE_SHOW, mController.getAnimationType(ime())); + + // additionally verify that IME ends up visible + mController.cancelExistingAnimations(); + assertTrue(mController.getState().peekSource(ID_IME).isVisible()); + }); + } + + @Test + public void testImeHideRequestIgnoredDuringPredictiveBackPostCommitAnim() { + prepareControls(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + // show ime as initial state + mController.show(ime(), true /* fromIme */, ImeTracker.Token.empty()); + mController.cancelExistingAnimations(); // fast forward show animation + mViewRoot.getView().getViewTreeObserver().dispatchOnPreDraw(); + assertTrue(mController.getState().peekSource(ID_IME).isVisible()); + + // start control request (for predictive back animation) + WindowInsetsAnimationControlListener listener = + mock(WindowInsetsAnimationControlListener.class); + mController.controlWindowInsetsAnimation(ime(), /*cancellationSignal*/ null, + listener, /*fromIme*/ false, /*duration*/ -1, /*interpolator*/ null, + ANIMATION_TYPE_USER, /*fromPredictiveBack*/ true); + + // verify that controller has ANIMATION_TYPE_USER set for ime() + assertEquals(ANIMATION_TYPE_USER, mController.getAnimationType(ime())); + + // verify hide request is ignored during post commit phase of predictive back anim + // since IME is already animating away + mController.setPredictiveBackImeHideAnimInProgress(true); + mController.hide(ime(), true /* fromIme */, null /* statsToken */); + assertEquals(ANIMATION_TYPE_USER, mController.getAnimationType(ime())); + }); + } + private void waitUntilNextFrame() throws Exception { final CountDownLatch latch = new CountDownLatch(1); Choreographer.getMainThreadInstance().postCallback(Choreographer.CALLBACK_COMMIT, diff --git a/tests/utils/testutils/java/com/android/server/wm/test/filters/FrameworksTestsFilter.java b/tests/utils/testutils/java/com/android/server/wm/test/filters/FrameworksTestsFilter.java index 7c5dcf8b95f7..e8be33cba3a1 100644 --- a/tests/utils/testutils/java/com/android/server/wm/test/filters/FrameworksTestsFilter.java +++ b/tests/utils/testutils/java/com/android/server/wm/test/filters/FrameworksTestsFilter.java @@ -51,6 +51,7 @@ public final class FrameworksTestsFilter extends SelectTest { "android.view.CutoutSpecificationTest", "android.view.DisplayCutoutTest", "android.view.DisplayShapeTest", + "android.view.ImeBackAnimationControllerTest", "android.view.InsetsAnimationControlImplTest", "android.view.InsetsControllerTest", "android.view.InsetsFlagsTest", |