Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 73415720 authored by Johannes Gallmann's avatar Johannes Gallmann
Browse files

Add tests for IME predictive back animation

Bug: 322836622
Flag: ACONFIG android.view.inputmethod.predictive_back_ime DISABLED
Test: atest FrameworksCoreTests:InsetsControllerTest
Test: atest FrameworksCoreTests:ImeBackAnimationControllerTest
Change-Id: I4f3691da317544a43721d4671f4e901d33c35b90
parent b8f5e5c7
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -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;
    }

+6 −3
Original line number Diff line number Diff line
@@ -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;
    }
}
+8 −2
Original line number Diff line number Diff line
@@ -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
     */
+257 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}
+90 −0
Original line number Diff line number Diff line
@@ -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,
Loading