/*
 * Copyright (C) 2020 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;

import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
import static android.server.wm.WindowInsetsAnimationControllerTests.ControlListener.Event.CANCELLED;
import static android.server.wm.WindowInsetsAnimationControllerTests.ControlListener.Event.FINISHED;
import static android.server.wm.WindowInsetsAnimationControllerTests.ControlListener.Event.READY;
import static android.server.wm.WindowInsetsAnimationUtils.INSETS_EVALUATOR;
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.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 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.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.
 *
 * Build/Install/Run:
 *     atest CtsWindowManagerDeviceTestCases:WindowInsetsAnimationControllerTests
 */
//TODO(b/159167851) @Presubmit
@RunWith(Parameterized.class)
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" }
        };
    }

    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.",
                isCar() && mType == ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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
    @Test
    public void testControl_transition_show() throws Throwable {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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 {
        assumeFalse(isCar() && remoteInsetsControllerControlsSystemBars() && mType != ime());

        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
    @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;

        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));
            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);
                    }
                    fail("Timeout waiting for " + event + "; reported events: " + reportedEvents());
                }
            } 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);
        }
    };
}
