/*
 * 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 android.server.wm.insets;

import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
import static android.server.wm.WindowInsetsAnimationUtils.INSETS_EVALUATOR;
import static android.server.wm.insets.WindowInsetsAnimationControllerTests.ControlListener.Event.CANCELLED;
import static android.server.wm.insets.WindowInsetsAnimationControllerTests.ControlListener.Event.FINISHED;
import static android.server.wm.insets.WindowInsetsAnimationControllerTests.ControlListener.Event.READY;
import static android.view.WindowInsets.Type.ime;
import static android.view.WindowInsets.Type.navigationBars;
import static android.view.WindowInsets.Type.statusBars;

import static androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;

import static com.android.cts.mockime.ImeEventStreamTestUtils.editorMatcher;
import static com.android.cts.mockime.ImeEventStreamTestUtils.expectEvent;

import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.sameInstance;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeFalse;
import static org.junit.Assume.assumeThat;
import static org.junit.Assume.assumeTrue;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.app.Instrumentation;
import android.graphics.Insets;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.platform.test.annotations.Presubmit;
import android.server.wm.WindowManagerTestBase;
import android.server.wm.WindowInsetsAnimationTestBase.TestActivity;
import android.util.Log;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.view.WindowInsetsAnimation.Callback;
import android.view.WindowInsetsAnimationControlListener;
import android.view.WindowInsetsAnimationController;
import android.view.WindowInsetsController.OnControllableInsetsChangedListener;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.view.animation.LinearInterpolator;
import android.view.inputmethod.InputMethodManager;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.filters.FlakyTest;

import com.android.compatibility.common.util.OverrideAnimationScaleRule;
import com.android.cts.mockime.ImeEventStream;
import com.android.cts.mockime.ImeSettings;
import com.android.cts.mockime.MockImeSession;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ErrorCollector;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * Test whether {@link android.view.WindowInsetsController#controlWindowInsetsAnimation} properly
 * works.
 *
 * <p>Build/Install/Run: atest CtsWindowManagerDeviceInsets:WindowInsetsAnimationControllerTests
 */
// TODO(b/159167851) @Presubmit
@RunWith(Parameterized.class)
@android.server.wm.annotation.Group2
public class WindowInsetsAnimationControllerTests extends WindowManagerTestBase {

    ControllerTestActivity mActivity;
    View mRootView;
    ControlListener mListener;
    CancellationSignal mCancellationSignal = new CancellationSignal();
    Interpolator mInterpolator;
    boolean mOnProgressCalled;
    private ValueAnimator mAnimator;
    List<VerifyingCallback> mCallbacks = new ArrayList<>();
    private boolean mLossOfControlExpected;

    public LimitedErrorCollector mErrorCollector = new LimitedErrorCollector();

    /**
     * {@link MockImeSession} used when {@link #mType} is {@link
     * android.view.WindowInsets.Type#ime()}.
     */
    @Nullable private MockImeSession mMockImeSession;

    @Parameter(0)
    public int mType;

    @Parameter(1)
    public String mTypeDescription;

    @Parameters(name = "{1}")
    public static Object[][] types() {
        return new Object[][] {
            {statusBars(), "statusBars"},
            {ime(), "ime"},
            {navigationBars(), "navigationBars"}
        };
    }

    @Rule
    public final OverrideAnimationScaleRule mEnableAnimationsRule =
            new OverrideAnimationScaleRule(1.0f);

    public static class ControllerTestActivity extends TestActivity {
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            // Ensure to set animation callback to null before starting a test. Otherwise, launching
            // this activity might trigger some inset animation accidentally.
            mView.setWindowInsetsAnimationCallback(null);
        }
    }

    @Before
    public void setUpWindowInsetsAnimationControllerTests() throws Throwable {
        assumeFalse(
                "In Automotive, auxiliary inset changes can happen when IME inset changes, so "
                        + "allow Automotive skip IME inset animation tests."
                        + "And if config_remoteInsetsControllerControlsSystemBars is enabled,"
                        + "SystemBar controls doesn't work, so allow skip inset animation tests.",
                isCar() && (mType == ime() || remoteInsetsControllerControlsSystemBars()));
        assertEquals(
                "Test precondition failed: ValueAnimator.getDurationScale()",
                1f,
                ValueAnimator.getDurationScale(),
                0.001);

        final ImeEventStream mockImeEventStream;
        if (mType == ime()) {
            final Instrumentation instrumentation = getInstrumentation();
            assumeThat(
                    MockImeSession.getUnavailabilityReason(instrumentation.getContext()),
                    nullValue());

            // For the best test stability MockIme should be selected before launching
            // ControllerTestActivity.
            mMockImeSession =
                    MockImeSession.create(
                            instrumentation.getContext(),
                            instrumentation.getUiAutomation(),
                            new ImeSettings.Builder());
            mockImeEventStream = mMockImeSession.openEventStream();
        } else {
            mockImeEventStream = null;
        }

        mActivity =
                startActivityInWindowingMode(
                        ControllerTestActivity.class, WINDOWING_MODE_FULLSCREEN);
        mRootView = mActivity.getWindow().getDecorView();
        mListener = new ControlListener(mErrorCollector);
        assumeTestCompatibility();

        if (mockImeEventStream != null) {
            // ControllerTestActivity has a focused EditText. Hence MockIme should receive
            // onStartInput() for that EditText within a reasonable time.
            expectEvent(
                    mockImeEventStream,
                    editorMatcher("onStartInput", mActivity.getEditTextMarker()),
                    TimeUnit.SECONDS.toMillis(10));
        }
        awaitControl(mType);
    }

    @After
    public void tearDown() throws Throwable {
        runOnUiThread(() -> {}); // Fence to make sure we dispatched everything.
        mCallbacks.forEach(VerifyingCallback::assertNoPendingAnimations);

        // Unregistering VerifyingCallback as tearing down the MockIme also triggers UI events,
        // which can trigger assertion failures in VerifyingCallback otherwise.
        runOnUiThread(
                () -> {
                    mCallbacks.clear();
                    if (mRootView != null) {
                        mRootView.setWindowInsetsAnimationCallback(null);
                    }
                });

        // Now it should be safe to reset the IME to the default one.
        if (mMockImeSession != null) {
            mMockImeSession.close();
            mMockImeSession = null;
        }
        mErrorCollector.verify();
    }

    private void assumeTestCompatibility() {
        if (mType == navigationBars() || mType == statusBars()) {
            assumeTrue(
                    Insets.NONE
                            != mRootView.getRootWindowInsets().getInsetsIgnoringVisibility(mType));
        }
    }

    private void awaitControl(int type) throws Throwable {
        CountDownLatch control = new CountDownLatch(1);
        OnControllableInsetsChangedListener listener =
                (controller, controllableTypes) -> {
                    if ((controllableTypes & type) != 0) control.countDown();
                };
        runOnUiThread(
                () ->
                        mRootView
                                .getWindowInsetsController()
                                .addOnControllableInsetsChangedListener(listener));
        try {
            if (!control.await(10, TimeUnit.SECONDS)) {
                fail("Timeout waiting for control of " + type);
            }
        } finally {
            runOnUiThread(
                    () ->
                            mRootView
                                    .getWindowInsetsController()
                                    .removeOnControllableInsetsChangedListener(listener));
        }
    }

    private void retryIfCancelled(ThrowableThrowingRunnable test) throws Throwable {
        try {
            mErrorCollector.verify();
            test.run();
        } catch (CancelledWhileWaitingForReadyException e) {
            // Deflake cancellations waiting for ready - we'll reset state and try again.
            runOnUiThread(
                    () -> {
                        mCallbacks.clear();
                        if (mRootView != null) {
                            mRootView.setWindowInsetsAnimationCallback(null);
                        }
                    });
            mErrorCollector = new LimitedErrorCollector();
            mListener = new ControlListener(mErrorCollector);
            awaitControl(mType);
            test.run();
        }
    }

    @Presubmit
    @Test
    public void testControl_andCancel() throws Throwable {
        retryIfCancelled(
                () -> {
                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, mCancellationSignal, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runOnUiThread(
                            () -> {
                                mCancellationSignal.cancel();
                            });

                    mListener.awaitAndAssert(CANCELLED);
                    mListener.assertWasNotCalled(FINISHED);
                });
    }

    @Test
    public void testControl_andImmediatelyCancel() throws Throwable {
        retryIfCancelled(
                () -> {
                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, mCancellationSignal, mListener);
                                mCancellationSignal.cancel();
                            });

                    mListener.assertWasCalled(CANCELLED);
                    mListener.assertWasNotCalled(READY);
                    mListener.assertWasNotCalled(FINISHED);
                });
    }

    @Presubmit
    @Test
    public void testControl_immediately_show() throws Throwable {
        retryIfCancelled(
                () -> {
                    setVisibilityAndWait(mType, false);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runOnUiThread(
                            () -> {
                                mListener.mController.finish(true);
                            });

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Presubmit
    @Test
    public void testControl_immediately_hide() throws Throwable {
        retryIfCancelled(
                () -> {
                    setVisibilityAndWait(mType, true);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runOnUiThread(
                            () -> {
                                mListener.mController.finish(false);
                            });

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Presubmit
    @FlakyTest(bugId = 339380439)
    @Test
    public void testControl_transition_show() throws Throwable {
        retryIfCancelled(
                () -> {
                    setVisibilityAndWait(mType, false);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(true);

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Presubmit
    @Test
    public void testControl_transition_hide() throws Throwable {
        retryIfCancelled(
                () -> {
                    setVisibilityAndWait(mType, true);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(false);

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Presubmit
    @Test
    public void testControl_transition_show_interpolator() throws Throwable {
        retryIfCancelled(
                () -> {
                    mInterpolator = new DecelerateInterpolator();
                    setVisibilityAndWait(mType, false);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, mInterpolator, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(true);

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Presubmit
    @Test
    public void testControl_transition_hide_interpolator() throws Throwable {
        retryIfCancelled(
                () -> {
                    mInterpolator = new AccelerateInterpolator();
                    setVisibilityAndWait(mType, true);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, mInterpolator, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(false);

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    @Test
    public void testControl_andLoseControl() throws Throwable {
        retryIfCancelled(
                () -> {
                    mInterpolator = new AccelerateInterpolator();
                    setVisibilityAndWait(mType, true);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, mInterpolator, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(false, TimeUnit.MINUTES.toMillis(5));
                    runOnUiThread(
                            () -> {
                                mLossOfControlExpected = true;
                            });
                    launchHomeActivityNoWait();

                    mListener.awaitAndAssert(CANCELLED);
                    mListener.assertWasNotCalled(FINISHED);
                });
    }

    @Presubmit
    @FlakyTest(bugId = 339380439)
    @Test
    public void testImeControl_isntInterruptedByStartingInput() throws Throwable {
        if (mType != ime()) {
            return;
        }

        retryIfCancelled(
                () -> {
                    setVisibilityAndWait(mType, false);

                    runOnUiThread(
                            () -> {
                                setupAnimationListener();
                                mRootView
                                        .getWindowInsetsController()
                                        .controlWindowInsetsAnimation(
                                                mType, 0, null, null, mListener);
                            });

                    mListener.awaitAndAssert(READY);

                    runTransition(true);
                    runOnUiThread(
                            () -> {
                                mActivity
                                        .getSystemService(InputMethodManager.class)
                                        .restartInput(mActivity.mEditor);
                            });

                    mListener.awaitAndAssert(FINISHED);
                    mListener.assertWasNotCalled(CANCELLED);
                });
    }

    private void setupAnimationListener() {
        WindowInsets initialInsets = mActivity.mLastWindowInsets;
        VerifyingCallback callback =
                new VerifyingCallback(
                        new Callback(Callback.DISPATCH_MODE_STOP) {
                            @Override
                            public void onPrepare(@NonNull WindowInsetsAnimation animation) {
                                mErrorCollector.checkThat(
                                        "onPrepare",
                                        mActivity.mLastWindowInsets.getInsets(mType),
                                        equalTo(initialInsets.getInsets(mType)));
                            }

                            @NonNull
                            @Override
                            public WindowInsetsAnimation.Bounds onStart(
                                    @NonNull WindowInsetsAnimation animation,
                                    @NonNull WindowInsetsAnimation.Bounds bounds) {
                                mErrorCollector.checkThat(
                                        "onStart",
                                        mActivity.mLastWindowInsets,
                                        not(equalTo(initialInsets)));
                                mErrorCollector.checkThat(
                                        "onStart",
                                        animation.getInterpolator(),
                                        sameInstance(mInterpolator));
                                return bounds;
                            }

                            @NonNull
                            @Override
                            public WindowInsets onProgress(
                                    @NonNull WindowInsets insets,
                                    @NonNull List<WindowInsetsAnimation> runningAnimations) {
                                mOnProgressCalled = true;
                                if (mAnimator != null) {
                                    float fraction = runningAnimations.get(0).getFraction();
                                    mErrorCollector.checkThat(
                                            String.format(Locale.US, "onProgress(%.2f)", fraction),
                                            insets.getInsets(mType),
                                            equalTo(mAnimator.getAnimatedValue()));
                                    mErrorCollector.checkThat(
                                            "onProgress",
                                            fraction,
                                            equalTo(mAnimator.getAnimatedFraction()));

                                    Interpolator interpolator =
                                            mInterpolator != null
                                                    ? mInterpolator
                                                    : new LinearInterpolator();
                                    mErrorCollector.checkThat(
                                            "onProgress",
                                            runningAnimations.get(0).getInterpolatedFraction(),
                                            equalTo(
                                                    interpolator.getInterpolation(
                                                            mAnimator.getAnimatedFraction())));
                                }
                                return insets;
                            }

                            @Override
                            public void onEnd(@NonNull WindowInsetsAnimation animation) {
                                mRootView.setWindowInsetsAnimationCallback(null);
                            }
                        });
        mCallbacks.add(callback);
        mRootView.setWindowInsetsAnimationCallback(callback);
    }

    private void runTransition(boolean show) throws Throwable {
        runTransition(show, 1000);
    }

    private void runTransition(boolean show, long durationMillis) throws Throwable {
        runOnUiThread(
                () -> {
                    mAnimator =
                            ValueAnimator.ofObject(
                                    INSETS_EVALUATOR,
                                    show
                                            ? mListener.mController.getHiddenStateInsets()
                                            : mListener.mController.getShownStateInsets(),
                                    show
                                            ? mListener.mController.getShownStateInsets()
                                            : mListener.mController.getHiddenStateInsets());
                    mAnimator.setDuration(durationMillis);
                    mAnimator.addUpdateListener(
                            (animator1) -> {
                                if (!mListener.mController.isReady()) {
                                    // Lost control - Don't crash the instrumentation below.
                                    if (!mLossOfControlExpected) {
                                        mErrorCollector.addError(
                                                new AssertionError("Unexpectedly lost control."));
                                    }
                                    mAnimator.cancel();
                                    return;
                                }
                                Insets insets = (Insets) mAnimator.getAnimatedValue();
                                mOnProgressCalled = false;
                                mListener.mController.setInsetsAndAlpha(
                                        insets, 1.0f, mAnimator.getAnimatedFraction());
                                mErrorCollector.checkThat(
                                        "setInsetsAndAlpha() must synchronously call onProgress()"
                                            + " but didn't",
                                        mOnProgressCalled,
                                        is(true));
                            });
                    mAnimator.addListener(
                            new AnimatorListenerAdapter() {
                                @Override
                                public void onAnimationEnd(Animator animation) {
                                    if (!mListener.mController.isCancelled()) {
                                        mListener.mController.finish(show);
                                    }
                                }
                            });

                    mAnimator.start();
                });
    }

    private void setVisibilityAndWait(int type, boolean visible) throws Throwable {
        assertThat(
                "setVisibilityAndWait must only be called before any"
                        + " WindowInsetsAnimation.Callback was registered",
                mCallbacks,
                equalTo(List.of()));

        final Set<WindowInsetsAnimation> runningAnimations = new HashSet<>();
        Callback callback =
                new Callback(Callback.DISPATCH_MODE_STOP) {

                    @NonNull
                    @Override
                    public void onPrepare(@NonNull WindowInsetsAnimation animation) {
                        synchronized (runningAnimations) {
                            runningAnimations.add(animation);
                        }
                    }

                    @NonNull
                    @Override
                    public WindowInsetsAnimation.Bounds onStart(
                            @NonNull WindowInsetsAnimation animation,
                            @NonNull WindowInsetsAnimation.Bounds bounds) {
                        synchronized (runningAnimations) {
                            runningAnimations.add(animation);
                        }
                        return bounds;
                    }

                    @NonNull
                    @Override
                    public WindowInsets onProgress(
                            @NonNull WindowInsets insets,
                            @NonNull List<WindowInsetsAnimation> runningAnimations) {
                        return insets;
                    }

                    @Override
                    public void onEnd(@NonNull WindowInsetsAnimation animation) {
                        synchronized (runningAnimations) {
                            runningAnimations.remove(animation);
                        }
                    }
                };
        runOnUiThread(
                () -> {
                    mRootView.setWindowInsetsAnimationCallback(callback);
                    if (visible) {
                        mRootView.getWindowInsetsController().show(type);
                    } else {
                        mRootView.getWindowInsetsController().hide(type);
                    }
                });

        waitForOrFail(
                "Timeout waiting for inset to become " + (visible ? "visible" : "invisible"),
                () -> mActivity.mLastWindowInsets.isVisible(mType) == visible);
        waitForOrFail(
                "Timeout waiting for animations to end, running=" + runningAnimations,
                () -> {
                    synchronized (runningAnimations) {
                        return runningAnimations.isEmpty();
                    }
                });

        runOnUiThread(
                () -> {
                    mRootView.setWindowInsetsAnimationCallback(null);
                });
    }

    static class ControlListener implements WindowInsetsAnimationControlListener {
        private final ErrorCollector mErrorCollector;

        WindowInsetsAnimationController mController = null;
        int mTypes = -1;
        RuntimeException mCancelledStack = null;
        RuntimeException mFinishedStack = null;

        ControlListener(ErrorCollector errorCollector) {
            mErrorCollector = errorCollector;
        }

        enum Event {
            READY,
            FINISHED,
            CANCELLED;
        }

        /** Latch for every callback event. */
        private CountDownLatch[] mLatches = {
            new CountDownLatch(1), new CountDownLatch(1), new CountDownLatch(1),
        };

        @Override
        public void onReady(@NonNull WindowInsetsAnimationController controller, int types) {
            mController = controller;
            mTypes = types;

            // Collect errors here and below, so we don't crash the main thread.
            mErrorCollector.checkThat(controller, notNullValue());
            mErrorCollector.checkThat(types, not(equalTo(0)));
            mErrorCollector.checkThat("isReady", controller.isReady(), is(true));
            mErrorCollector.checkThat("isFinished", controller.isFinished(), is(false));
            mErrorCollector.checkThat("isCancelled", controller.isCancelled(), is(false));
            report(READY);
        }

        @Override
        public void onFinished(@NonNull WindowInsetsAnimationController controller) {
            mErrorCollector.checkThat(controller, notNullValue());
            mErrorCollector.checkThat(controller, sameInstance(mController));
            mErrorCollector.checkThat("isReady", controller.isReady(), is(false));
            mErrorCollector.checkThat("isFinished", controller.isFinished(), is(true));
            mErrorCollector.checkThat("isCancelled", controller.isCancelled(), is(false));
            mFinishedStack = new RuntimeException("onFinished called here");
            report(FINISHED);
        }

        @Override
        public void onCancelled(@Nullable WindowInsetsAnimationController controller) {
            mErrorCollector.checkThat(controller, sameInstance(mController));
            if (controller != null) {
                mErrorCollector.checkThat("isReady", controller.isReady(), is(false));
                mErrorCollector.checkThat("isFinished", controller.isFinished(), is(false));
                mErrorCollector.checkThat("isCancelled", controller.isCancelled(), is(true));
            }
            mCancelledStack = new RuntimeException("onCancelled called here");
            report(CANCELLED);
        }

        private void report(Event event) {
            CountDownLatch latch = mLatches[event.ordinal()];
            mErrorCollector.checkThat(event + ": count", latch.getCount(), is(1L));
            latch.countDown();
        }

        void awaitAndAssert(Event event) {
            CountDownLatch latch = mLatches[event.ordinal()];
            try {
                if (!latch.await(10, TimeUnit.SECONDS)) {
                    if (event == READY && mCancelledStack != null) {
                        throw new CancelledWhileWaitingForReadyException(
                                "expected " + event + " but instead got " + CANCELLED,
                                mCancelledStack);
                    }
                    Throwable unexpectedStack = null;
                    if (event == CANCELLED) {
                        unexpectedStack = mFinishedStack;
                    } else if (event == FINISHED) {
                        unexpectedStack = mCancelledStack;
                    }
                    throw new AssertionError(
                            "Timeout waiting for "
                                    + event
                                    + "; reported events: "
                                    + reportedEvents(),
                            unexpectedStack);
                }
            } catch (InterruptedException e) {
                throw new AssertionError("Interrupted", e);
            }
        }

        void assertWasCalled(Event event) {
            CountDownLatch latch = mLatches[event.ordinal()];
            assertEquals(
                    event + " expected, but never called; called: " + reportedEvents(),
                    0,
                    latch.getCount());
        }

        void assertWasNotCalled(Event event) {
            CountDownLatch latch = mLatches[event.ordinal()];
            assertEquals(
                    event + " not expected, but was called; called: " + reportedEvents(),
                    1,
                    latch.getCount());
        }

        String reportedEvents() {
            return Arrays.stream(Event.values())
                    .filter((e) -> mLatches[e.ordinal()].getCount() == 0)
                    .map(Enum::toString)
                    .collect(Collectors.joining(",", "<", ">"));
        }
    }

    private class VerifyingCallback extends Callback {
        private final Callback mInner;
        private final Set<WindowInsetsAnimation> mPreparedAnimations = new HashSet<>();
        private final Set<WindowInsetsAnimation> mRunningAnimations = new HashSet<>();
        private final Set<WindowInsetsAnimation> mEndedAnimations = new HashSet<>();

        public VerifyingCallback(Callback callback) {
            super(callback.getDispatchMode());
            mInner = callback;
        }

        @Override
        public void onPrepare(@NonNull WindowInsetsAnimation animation) {
            mErrorCollector.checkThat("onPrepare: animation", animation, notNullValue());
            mErrorCollector.checkThat("onPrepare", mPreparedAnimations, not(hasItem(animation)));
            mPreparedAnimations.add(animation);
            mInner.onPrepare(animation);
        }

        @NonNull
        @Override
        public WindowInsetsAnimation.Bounds onStart(
                @NonNull WindowInsetsAnimation animation,
                @NonNull WindowInsetsAnimation.Bounds bounds) {
            mErrorCollector.checkThat("onStart: animation", animation, notNullValue());
            mErrorCollector.checkThat("onStart: bounds", bounds, notNullValue());

            mErrorCollector.checkThat(
                    "onStart: mPreparedAnimations", mPreparedAnimations, hasItem(animation));
            mErrorCollector.checkThat(
                    "onStart: mRunningAnimations", mRunningAnimations, not(hasItem(animation)));
            mRunningAnimations.add(animation);
            mPreparedAnimations.remove(animation);
            return mInner.onStart(animation, bounds);
        }

        @NonNull
        @Override
        public WindowInsets onProgress(
                @NonNull WindowInsets insets,
                @NonNull List<WindowInsetsAnimation> runningAnimations) {
            mErrorCollector.checkThat("onProgress: insets", insets, notNullValue());
            mErrorCollector.checkThat(
                    "onProgress: runningAnimations", runningAnimations, notNullValue());

            mErrorCollector.checkThat(
                    "onProgress",
                    new HashSet<>(runningAnimations),
                    is(equalTo(mRunningAnimations)));
            return mInner.onProgress(insets, runningAnimations);
        }

        @Override
        public void onEnd(@NonNull WindowInsetsAnimation animation) {
            mErrorCollector.checkThat("onEnd: animation", animation, notNullValue());

            mErrorCollector.checkThat(
                    "onEnd for this animation was already dispatched",
                    mEndedAnimations,
                    not(hasItem(animation)));
            mErrorCollector.checkThat(
                    "onEnd: animation must be either running or prepared",
                    mRunningAnimations.contains(animation)
                            || mPreparedAnimations.contains(animation),
                    is(true));
            mRunningAnimations.remove(animation);
            mPreparedAnimations.remove(animation);
            mEndedAnimations.add(animation);
            mInner.onEnd(animation);
        }

        public void assertNoPendingAnimations() {
            mErrorCollector.checkThat(
                    "Animations with onStart but missing onEnd:",
                    mRunningAnimations,
                    equalTo(Set.of()));
            mErrorCollector.checkThat(
                    "Animations with onPrepare but missing onStart:",
                    mPreparedAnimations,
                    equalTo(Set.of()));
        }
    }

    public static final class LimitedErrorCollector extends ErrorCollector {
        private static final int THROW_LIMIT = 1;
        private static final int LOG_LIMIT = 10;
        private static final boolean REPORT_SUPPRESSED_ERRORS_AS_THROWABLE = false;
        private int mCount = 0;
        private List<Throwable> mSuppressedErrors = new ArrayList<>();

        @Override
        public void addError(Throwable error) {
            if (mCount < THROW_LIMIT) {
                super.addError(error);
            } else if (mCount < LOG_LIMIT) {
                mSuppressedErrors.add(error);
            }
            mCount++;
        }

        @Override
        protected void verify() throws Throwable {
            if (mCount > THROW_LIMIT) {
                if (REPORT_SUPPRESSED_ERRORS_AS_THROWABLE) {
                    super.addError(
                            new AssertionError((mCount - THROW_LIMIT) + " errors suppressed."));
                } else {
                    Log.i(
                            "LimitedErrorCollector",
                            (mCount - THROW_LIMIT) + " errors suppressed; " + "additional errors:");
                    for (Throwable t : mSuppressedErrors) {
                        Log.e("LimitedErrorCollector", "", t);
                    }
                }
            }
            super.verify();
        }
    }

    private interface ThrowableThrowingRunnable {
        void run() throws Throwable;
    }

    private static class CancelledWhileWaitingForReadyException extends AssertionError {
        public CancelledWhileWaitingForReadyException(String message, Throwable cause) {
            super(message, cause);
        }
    }
    ;
}
