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

// Copyright 2020 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 android.animation.Animator;
import android.content.res.Resources;
import android.provider.Settings;
import android.view.View;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.supplier.Supplier;
import org.chromium.build.annotations.MockedInTests;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.ui.listmenu.ListMenuButton.PopupMenuShownListener;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;

/** Coordinator responsible for creating a message banner. */
@MockedInTests
class MessageBannerCoordinator {
    private final MessageBannerMediator mMediator;
    private final MessageBannerView mView;
    private final PropertyModel mModel;
    private final MessageAutoDismissTimer mTimer;
    private final Supplier<Long> mAutodismissDurationMs;
    private final Runnable mOnTimeUp;

    /**
     * Constructs the message banner.
     *
     * @param view The inflated {@link MessageBannerView}.
     * @param model The model for the message banner.
     * @param maxTranslationSupplier A {@link Supplier} that supplies the maximum translation Y
     * value the message banner can have as a result of the animations or the gestures.
     * @param topOffsetSupplier A {@link Supplier} that supplies the message's top offset.
     * @param resources The {@link Resources}.
     * @param messageDismissed The {@link Runnable} that will run if and when the user dismisses the
     * message.
     * @param swipeAnimationHandler The handler that will be used to delegate starting the
     * animations to {@link WindowAndroid} so the message is not clipped as a result of some Android
     * SurfaceView optimization.
     * @param autodismissDurationMs A {@link Supplier} providing autodismiss duration for message
     * banner.
     * @param onTimeUp A {@link Runnable} that will run if and when the auto dismiss timer is up.
     */
    MessageBannerCoordinator(
            MessageBannerView view,
            PropertyModel model,
            Supplier<Integer> maxTranslationSupplier,
            Supplier<Integer> topOffsetSupplier,
            Resources resources,
            Runnable messageDismissed,
            SwipeAnimationHandler swipeAnimationHandler,
            Supplier<Long> autodismissDurationMs,
            Runnable onTimeUp) {
        mView = view;
        mModel = model;
        PropertyModelChangeProcessor.create(model, view, MessageBannerViewBinder::bind);
        mMediator =
                new MessageBannerMediator(
                        model,
                        topOffsetSupplier,
                        maxTranslationSupplier,
                        resources,
                        messageDismissed,
                        swipeAnimationHandler);
        mAutodismissDurationMs = autodismissDurationMs;
        mTimer = new MessageAutoDismissTimer();
        mOnTimeUp = onTimeUp;
        view.setSwipeHandler(mMediator);
        view.setPopupMenuShownListener(
                createPopupMenuShownListener(mTimer, mAutodismissDurationMs.get(), mOnTimeUp));
    }

    /**
     * Creates a {@link PopupMenuShownListener} to handle secondary button popup menu events on the
     * message banner.
     * @param timer The {@link MessageAutoDismissTimer} controlling the message banner dismiss
     *         duration.
     * @param duration The auto dismiss duration for the message banner.
     * @param onTimeUp A {@link Runnable} that will run if and when the auto dismiss timer is up.
     */
    @VisibleForTesting
    PopupMenuShownListener createPopupMenuShownListener(
            MessageAutoDismissTimer timer, long duration, Runnable onTimeUp) {
        return new PopupMenuShownListener() {
            @Override
            public void onPopupMenuShown() {
                timer.cancelTimer();
            }

            @Override
            public void onPopupMenuDismissed() {
                timer.startTimer(duration, onTimeUp);
            }
        };
    }

    /**
     * Shows the message banner.
     * @param fromIndex The initial position.
     * @param toIndex The target position the message is moving to.
     * @param messageDimensSupplier Supplier of dimensions of the message next to current one.
     * @return The animator which shows the message view.
     */
    Animator show(
            @Position int fromIndex,
            @Position int toIndex,
            Supplier<MessageDimens> messageDimensSupplier) {
        mView.dismissSecondaryMenuIfShown();
        int verticalOffset = 0;
        if (toIndex == Position.BACK) {
            MessageDimens prevMessageDimens = messageDimensSupplier.get();
            int height = mView.getHeight();
            if (!mView.isLaidOut()) {
                int maxWidth = prevMessageDimens.getWidth();
                int wSpec = View.MeasureSpec.makeMeasureSpec(maxWidth, View.MeasureSpec.AT_MOST);
                int hSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
                mView.measure(wSpec, hSpec);
                height = mView.getMeasuredHeight();
            }
            if (height < prevMessageDimens.getHeight()) {
                verticalOffset = prevMessageDimens.getHeight() - height;
            } else if (height > prevMessageDimens.getHeight()) {
                mView.resizeForStackingAnimation(
                        prevMessageDimens.getTitleHeight(),
                        prevMessageDimens.getDescriptionHeight(),
                        prevMessageDimens.getPrimaryButtonLineCount());
            }
        } else if (fromIndex == Position.BACK && toIndex == Position.FRONT) {
            mView.resetForStackingAnimation();
        }
        return mMediator.show(
                fromIndex,
                toIndex,
                verticalOffset,
                () -> {
                    if (toIndex != Position.FRONT) {
                        setOnTouchRunnable(null);
                        setOnTitleChanged(null);
                        mTimer.cancelTimer();
                        // Make it unable to be focused if it is not in the front.
                        mView.enableA11y(false);
                        announceForAccessibility(toIndex);
                    } else {
                        mView.enableA11y(true);
                        setOnTouchRunnable(mTimer::resetTimer);
                        announceForAccessibility(toIndex);
                        setOnTitleChanged(
                                () -> {
                                    mTimer.resetTimer();
                                    announceForAccessibility(toIndex);
                                });
                        mTimer.startTimer(mAutodismissDurationMs.get(), mOnTimeUp);
                    }
                });
    }

    /**
     * Hides the message banner.
     * @param fromIndex The initial position.
     * @param toIndex The target position the message is moving to.
     * @param animate Whether to hide with an animation.
     * @param messageHidden The {@link Runnable} that will run once the message banner is hidden.
     * @return The animator which hides the message view.
     */
    Animator hide(
            @Position int fromIndex,
            @Position int toIndex,
            boolean animate,
            Runnable messageHidden) {
        mView.dismissSecondaryMenuIfShown();
        mTimer.cancelTimer();
        // Skip animation if animation has been globally disabled.
        // Otherwise, child animator's listener's onEnd will be called immediately after onStart,
        // even before parent animatorSet's listener's onStart.
        var isAnimationDisabled =
                Settings.Global.getFloat(
                                mView.getContext().getContentResolver(),
                                Settings.Global.ANIMATOR_DURATION_SCALE,
                                1f)
                        == 0;
        return mMediator.hide(
                fromIndex,
                toIndex,
                animate && !isAnimationDisabled,
                () -> {
                    setOnTouchRunnable(null);
                    setOnTitleChanged(null);
                    messageHidden.run();
                });
    }

    void cancelTimer() {
        mTimer.cancelTimer();
    }

    void startTimer() {
        mTimer.startTimer(mAutodismissDurationMs.get(), mOnTimeUp);
    }

    void setOnTouchRunnable(Runnable runnable) {
        mMediator.setOnTouchRunnable(runnable);
    }

    private void announceForAccessibility(int toIndex) {
        String msg = "";
        if (toIndex == Position.FRONT) {
            msg =
                    mModel.get(MessageBannerProperties.TITLE)
                            + " "
                            + mView.getResources().getString(R.string.message_screen_position);
        } else {
            msg = mView.getResources().getString(R.string.message_new_actions_available);
        }
        mView.announceForAccessibility(msg);
    }

    private void setOnTitleChanged(Runnable runnable) {
        mView.setOnTitleChanged(runnable);
    }
}