chromium/components/messages/android/internal/java/src/org/chromium/components/messages/MessageAnimationCoordinator.java

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.messages;

import static org.chromium.components.messages.MessagesMetrics.recordStackingAnimationType;
import static org.chromium.components.messages.MessagesMetrics.recordThreeStackedScenario;

import android.animation.Animator;
import android.animation.AnimatorSet;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.chromium.base.Callback;
import org.chromium.components.browser_ui.widget.animation.CancelAwareAnimatorListener;
import org.chromium.components.messages.MessageQueueManager.MessageState;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.components.messages.MessagesMetrics.StackingAnimationAction;
import org.chromium.components.messages.MessagesMetrics.StackingAnimationType;
import org.chromium.components.messages.MessagesMetrics.ThreeStackedScenario;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/** Coordinator for toggling animation when message is about to show or hide. */
public class MessageAnimationCoordinator implements SwipeAnimationHandler {
    private static final String TAG = MessageQueueManager.TAG;
    // Animation start delay for the back message for MessageBannerMediator.ENTER_DURATION_MS amount
    // of time, required to show the front message from Position.INVISIBLE to Position.FRONT.
    private static final int BACK_MESSAGE_START_DELAY_MS = 600;

    /**
     * mCurrentDisplayedMessage refers to the message which is currently visible on the screen
     * including situations in which the message is already dismissed and hide animation is running.
     */
    @Nullable private MessageState mCurrentDisplayedMessage;

    @NonNull private List<MessageState> mCurrentDisplayedMessages = Arrays.asList(null, null);
    private MessageState mLastShownMessage;
    private MessageQueueDelegate mMessageQueueDelegate;
    private AnimatorSet mAnimatorSet = new AnimatorSet();
    private Animator mFrontAnimator;
    private Animator mBackAnimator;
    private final MessageContainer mContainer;
    private final Callback<Animator> mAnimatorStartCallback;
    private final boolean mAreExtraHistogramsEnabled;

    public MessageAnimationCoordinator(
            MessageContainer messageContainer, Callback<Animator> animatorStartCallback) {
        mContainer = messageContainer;
        mAnimatorStartCallback = animatorStartCallback;
        mAreExtraHistogramsEnabled = MessageFeatureList.areExtraHistogramsEnabled();
    }

    // TODO(crbug.com/40762119): Compare current shown messages with last shown ones.
    /**
     * cf: Current front message. cb: Current back message. nf: Next front message. nb: Next back
     * message. Null represents no view at that position. 1. If candidates and current displayed
     * messages are internally equal, do nothing. 2. If cf is null, which implies cb is also null,
     * show candidates. 3. If cf is not found in candidates, it must be hidden. In the meantime, if
     * current back message is displayed, check if it should be hidden or moved to front. 4. If only
     * back message is changed: Hide current back message if possible; otherwise, show the
     * candidate. 5. The current front message must be moved back and a new message is moved to
     * front.
     *
     * <p>Note: Assume current displayed messages are [m1, m2]; Then the candidates won't be [m3,
     * m2]. If m3 is a higher priority message, then the candidates should be [m3, m1]. Otherwise,
     * m1 is usually hidden because of dismissing or inactive scope, the candidates should be [m2,
     * null/m3].
     *
     * <p>[m1, m2] -> [m3, m4] should also be impossible, because message is designed to be
     * dismissed one by one. If both are hiding by queue suspending, it should look like: [m1, m2]
     * -> [null, null] -> [m3, m4]
     *
     * @param candidates The candidates supposed to be displayed next. Not all candidates are
     *     guaranteed to be displayed after update. The content may be changed to reflect the actual
     *     change in this update.
     * @param isSuspended Whether the queue is suspended.
     * @param onFinished Runnable triggered after animation is finished.
     */
    public void updateWithStacking(
            @NonNull List<MessageState> candidates, boolean isSuspended, Runnable onFinished) {
        if (mMessageQueueDelegate.isDestroyed()) return;
        // Wait until the current animation is done, unless we need to hide them immediately.
        if (mAnimatorSet.isStarted()) {
            if (isSuspended) {
                // crbug.com/1405389: Force animation to end in order to trigger callbacks.
                mAnimatorSet.end();
                onFinished.run();
            }
            return;
        }

        var currentFront = mCurrentDisplayedMessages.get(0); // Currently front.
        var currentBack = mCurrentDisplayedMessages.get(1); // Currently back.
        var nextFront = candidates.get(0); // Next front.
        var nextBack = candidates.get(1); // Next back.

        // If front message is null, then the back one is definitely null.
        assert !(nextFront == null && nextBack != null);
        assert !(currentFront == null && currentBack != null);
        assert !isSuspended || nextFront == null : "when suspending, all messages should be hidden";
        if (currentFront == nextFront && currentBack == nextBack) {
            if (currentFront == null && mMessageQueueDelegate.isReadyForShowing()) {
                mMessageQueueDelegate.onFinishHiding();
            }
            return;
        }

        if (mAreExtraHistogramsEnabled && currentFront != nextFront && nextFront != null) {
            MessagesMetrics.recordRequestToFullyShow(nextFront.handler.getMessageIdentifier());
        }

        if (!isSuspended && !mMessageQueueDelegate.isReadyForShowing()) {
            // Make sure everything is ready for showing a message, unless messages are about to
            // be removed immediately. By "showing", it does mean not just triggering a showing
            // animation, but also holding a message view on screen.
            // https://crbug.com/1408627: when showing a second message, it is possible the first
            // message is still waiting for message queue delegate to be ready.
            // Only request to show a message if not requested yet.
            if (!mMessageQueueDelegate.isPendingShow()) {
                mMessageQueueDelegate.onRequestShowing(onFinished);
            }
            if (mAreExtraHistogramsEnabled && currentFront != nextFront && nextFront != null) {
                MessagesMetrics.recordBlockedByBrowserControl(
                        nextFront.handler.getMessageIdentifier());
            }
            return;
        }

        // Similar to above scenario, second message is about trigger another animation while first
        // message is still waiting its animation to be triggered. Early return to avoid cancelling
        // that animation accidentally. Second message will be added after its animation is done.
        if (mContainer.isIsInitializingLayout()) {
            if (mAreExtraHistogramsEnabled && currentFront != nextFront && nextFront != null) {
                MessagesMetrics.recordBlockedByContainerInitializing(
                        nextFront.handler.getMessageIdentifier());
            }
            return;
        }

        // If both animators will be modified, modify FrontAnimator first, because the back message
        // relies on the first message in order to adjust its size.
        mFrontAnimator = mBackAnimator = null;
        boolean animate = !isSuspended;

        if (currentFront == null) { // Implies that currently back is also null.
            recordAnimationAction(StackingAnimationAction.INSERT_AT_FRONT, nextFront);
            mFrontAnimator = nextFront.handler.show(Position.INVISIBLE, Position.FRONT);
            if (nextBack != null) {
                recordAnimationAction(StackingAnimationAction.INSERT_AT_BACK, nextBack);
                recordStackingAnimationType(StackingAnimationType.SHOW_ALL);
                mBackAnimator = nextBack.handler.show(Position.FRONT, Position.BACK);
                if (mBackAnimator != null) {
                    mBackAnimator.setStartDelay(BACK_MESSAGE_START_DELAY_MS);
                }
            } else {
                recordStackingAnimationType(StackingAnimationType.SHOW_FRONT_ONLY);
            }
        } else if (currentFront != nextFront && currentFront != nextBack) {
            // Current displayed front message will be hidden.
            recordAnimationAction(StackingAnimationAction.REMOVE_FRONT, currentFront);
            mFrontAnimator = currentFront.handler.hide(Position.FRONT, Position.INVISIBLE, animate);
            if (currentBack != null) {
                if (currentBack == nextFront) { // Visible front will be dismissed and back one is
                    // moved to front.
                    recordAnimationAction(StackingAnimationAction.PUSH_TO_FRONT, currentBack);
                    recordStackingAnimationType(StackingAnimationType.REMOVE_FRONT_AND_SHOW_BACK);
                    mBackAnimator = currentBack.handler.show(Position.BACK, Position.FRONT);
                    if (nextBack != null) {
                        recordThreeStackedScenario(ThreeStackedScenario.IN_SEQUENCE);
                    }
                    // Show nb in the next round.
                    nextBack = null;
                    candidates.set(1, null);
                } else { // Both visible front and back messages will be replaced.
                    recordAnimationAction(StackingAnimationAction.REMOVE_BACK, currentBack);
                    recordStackingAnimationType(StackingAnimationType.REMOVE_ALL);
                    mBackAnimator =
                            currentBack.handler.hide(Position.BACK, Position.FRONT, animate);
                    // Hide current displayed two messages and then show other messages
                    // in the next round.
                    nextFront = nextBack = null;
                    candidates.set(0, null);
                    candidates.set(1, null);
                }
            } else {
                // TODO(crbug.com/40877229): simplify this into one step.
                // Split the transition: [m1, null] -> [m2, null] into two steps:
                // [m1, null] -> [null, null] -> [m2, null]
                nextFront = nextBack = null;
                candidates.set(0, null);
                candidates.set(1, null);
                recordStackingAnimationType(StackingAnimationType.REMOVE_FRONT_ONLY);
            }
        } else if (currentFront == nextFront) {
            if (currentBack != null) { // Hide the current back one.
                recordAnimationAction(StackingAnimationAction.REMOVE_BACK, currentBack);
                recordStackingAnimationType(StackingAnimationType.REMOVE_BACK_ONLY);
                mBackAnimator = currentBack.handler.hide(Position.BACK, Position.FRONT, animate);
                candidates.set(1, null); // Show next back in next round if non-null.
            } else {
                recordAnimationAction(StackingAnimationAction.INSERT_AT_BACK, nextBack);
                recordStackingAnimationType(StackingAnimationType.SHOW_BACK_ONLY);
                // If nb is null, it means candidates and current displayed messages are equal.
                assert nextBack != null;
                mBackAnimator = nextBack.handler.show(Position.FRONT, Position.BACK);
            }
        } else {
            assert currentFront == nextBack;
            if (currentBack != null) {
                recordAnimationAction(StackingAnimationAction.REMOVE_BACK, currentBack);
                recordStackingAnimationType(StackingAnimationType.REMOVE_BACK_ONLY);
                recordThreeStackedScenario(ThreeStackedScenario.HIGH_PRIORITY);
                mBackAnimator = currentBack.handler.hide(Position.BACK, Position.FRONT, animate);
                // [m1, m2] -> [m1, null] -> [m3, m1]
                // In this case, we complete this in 2 steps to avoid manipulating 3 handlers
                // at any single moment.
                candidates.set(0, currentFront);
                candidates.set(1, null);
            } else { // Moved the current front to back and show a new front view.
                recordAnimationAction(StackingAnimationAction.PUSH_TO_BACK, currentFront);
                recordAnimationAction(StackingAnimationAction.INSERT_AT_FRONT, nextFront);
                recordStackingAnimationType(StackingAnimationType.INSERT_AT_FRONT);
                mFrontAnimator = nextFront.handler.show(Position.INVISIBLE, Position.FRONT);
                mBackAnimator = currentFront.handler.show(Position.FRONT, Position.BACK);
            }
        }

        if (candidates.get(0) != null && candidates.get(1) != null) {
            MessagesMetrics.recordStackingHiding(candidates.get(0).handler.getMessageIdentifier());
            MessagesMetrics.recordStackingHidden(candidates.get(1).handler.getMessageIdentifier());
        }

        if (nextFront == null) {
            // All messages will be hidden: trigger #onFinishHiding.
            Runnable runnable =
                    () -> {
                        mMessageQueueDelegate.onFinishHiding();
                        mCurrentDisplayedMessages = new ArrayList<>(candidates);
                        onFinished.run();
                    };
            triggerStackingAnimation(candidates, runnable, mFrontAnimator, mBackAnimator);
        } else {
            assert mMessageQueueDelegate.isReadyForShowing();
            mCurrentDisplayedMessages = new ArrayList<>(candidates);
            triggerStackingAnimation(candidates, onFinished, mFrontAnimator, mBackAnimator);
        }
    }

    private void triggerStackingAnimation(
            List<MessageState> candidates,
            Runnable onFinished,
            Animator frontAnimator,
            Animator backAnimator) {
        Runnable runnable =
                () -> {
                    // While the runnable is waiting to be triggered, hiding animation might be
                    // triggered: while the hiding animation is running, declare this runnable as
                    // obsolete so that it won't cancel the hiding animation.
                    if (isAnimatorExpired(frontAnimator, backAnimator)) {
                        return;
                    }
                    mAnimatorSet.cancel();
                    mAnimatorSet.removeAllListeners();
                    mAnimatorSet = new AnimatorSet();
                    mAnimatorSet.play(frontAnimator);
                    mAnimatorSet.play(backAnimator);
                    mAnimatorSet.addListener(
                            new MessageAnimationListener(
                                    () -> {
                                        mMessageQueueDelegate.onAnimationEnd();
                                        onFinished.run();
                                    }));
                    mMessageQueueDelegate.onAnimationStart();
                    mAnimatorStartCallback.onResult(mAnimatorSet);
                };
        if (candidates.get(0) == null) {
            runnable.run();
        } else {
            boolean initialized = mContainer.runAfterInitialMessageLayout(runnable);
            if (mAreExtraHistogramsEnabled && !initialized) {
                MessagesMetrics.recordBlockedByContainerNotInitialized(
                        candidates.get(0).handler.getMessageIdentifier());
            }
        }
    }

    private boolean isAnimatorExpired(Animator frontAnimator, Animator backAnimator) {
        return mFrontAnimator != frontAnimator || mBackAnimator != backAnimator;
    }

    @Override
    public void onSwipeStart() {
        // Message shouldn't consume swipe for now because animation is running, e.g.:
        // the front message should not be swiped when back message is running showing animation.
        assert isSwipeEnabled();
        mMessageQueueDelegate.onAnimationStart();
    }

    @Override
    public boolean isSwipeEnabled() {
        return !mAnimatorSet.isStarted();
    }

    @Override
    public void onSwipeEnd(@Nullable Animator animator) {
        if (animator == null) {
            mMessageQueueDelegate.onAnimationEnd();
            return;
        }
        animator.addListener(new MessageAnimationListener(mMessageQueueDelegate::onAnimationEnd));
        mAnimatorStartCallback.onResult(animator);
    }

    void setMessageQueueDelegate(MessageQueueDelegate delegate) {
        mMessageQueueDelegate = delegate;
    }

    @Nullable
    MessageState getCurrentDisplayedMessage() {
        return mCurrentDisplayedMessage;
    }

    // Return a list of two messages which should be displayed when stacking animation is enabled.
    @NonNull
    List<MessageState> getCurrentDisplayedMessages() {
        return mCurrentDisplayedMessages;
    }

    private void recordAnimationAction(
            @StackingAnimationAction int action, @NonNull MessageState messageState) {
        MessagesMetrics.recordStackingAnimationAction(
                action, messageState.handler.getMessageIdentifier());
    }

    class MessageAnimationListener extends CancelAwareAnimatorListener {
        private final Runnable mOnFinished;

        public MessageAnimationListener(Runnable onFinished) {
            mOnFinished = onFinished;
        }

        @Override
        public void onEnd(Animator animator) {
            super.onEnd(animator);
            mOnFinished.run();
        }
    }

    AnimatorSet getAnimatorSetForTesting() {
        return mAnimatorSet;
    }
}