chromium/components/messages/android/internal/java/src/org/chromium/components/messages/SingleActionMessage.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.view.LayoutInflater;
import android.view.View;

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

import org.chromium.base.Callback;
import org.chromium.base.supplier.Supplier;
import org.chromium.components.messages.MessageContainer.MessageContainerA11yDelegate;
import org.chromium.ui.modelutil.PropertyModel;

/** Coordinator to show / hide a banner message on given container and delegate events. */
public class SingleActionMessage implements MessageStateHandler, MessageContainerA11yDelegate {
    /**
     * The interface that consumers of SingleActionMessage should implement to receive notification
     * that the message was dismissed.
     */
    @FunctionalInterface
    public interface DismissCallback {
        void invoke(PropertyModel messageProperties, int dismissReason);
    }

    @Nullable private MessageBannerCoordinator mMessageBanner;
    private MessageBannerView mView;
    private final MessageContainer mContainer;
    private final PropertyModel mModel;
    private final DismissCallback mDismissHandler;
    private final Supplier<Long> mAutodismissDurationMs;
    private final Supplier<Integer> mTopOffsetSupplier;
    private final Supplier<Integer> mMaxTranslationSupplier;
    private final SwipeAnimationHandler mSwipeAnimationHandler;
    private final boolean mIsFullyVisibileCallbackEnabled;
    private boolean mMessageDismissed;
    private boolean mFullyVisibleBefore;
    private boolean mFullyVisibleCallbackInvoked;
    private final boolean mAreExtraHistogramsEnabled;

    private long mMessageEnqueuedTime;
    // The timestamp when the message was shown. Used for reproting visible duration.
    private long mMessageShownTime;

    /**
     * @param container The container holding messages.
     * @param model The PropertyModel with {@link MessageBannerProperties#ALL_KEYS}.
     * @param dismissHandler The {@link DismissCallback} able to dismiss a message by given property
     *     model.
     * @param maxTranslationSupplier A {@link Supplier} that supplies the maximum translation Y.
     * @param topOffsetSupplier A {@link Supplier} that supplies the message's top offset.
     * @param autodismissDurationProvider A {@link MessageAutodismissDurationProvider} providing
     *     autodismiss duration for message banner. The actual duration can be extended by clients.
     * @param swipeAnimationHandler The Handler that will be used by the message banner to delegate
     *     starting custom swiping animations to the {@link WindowAndroid}.
     */
    public SingleActionMessage(
            MessageContainer container,
            PropertyModel model,
            DismissCallback dismissHandler,
            Supplier<Integer> maxTranslationSupplier,
            Supplier<Integer> topOffsetSupplier,
            MessageAutodismissDurationProvider autodismissDurationProvider,
            SwipeAnimationHandler swipeAnimationHandler) {
        mModel = model;
        mContainer = container;
        mDismissHandler = dismissHandler;
        mTopOffsetSupplier = topOffsetSupplier;
        mMaxTranslationSupplier = maxTranslationSupplier;
        mSwipeAnimationHandler = swipeAnimationHandler;
        mIsFullyVisibileCallbackEnabled = MessageFeatureList.isFullyVisibleCallbackEnabled();

        long dismissalDuration =
                mModel.getAllSetProperties().contains(MessageBannerProperties.DISMISSAL_DURATION)
                        ? mModel.get(MessageBannerProperties.DISMISSAL_DURATION)
                        : 0;

        mAutodismissDurationMs =
                () ->
                        autodismissDurationProvider.get(
                                model.get(MessageBannerProperties.MESSAGE_IDENTIFIER),
                                dismissalDuration);

        mModel.set(
                MessageBannerProperties.PRIMARY_BUTTON_CLICK_LISTENER, this::handlePrimaryAction);
        mModel.set(MessageBannerProperties.ON_SECONDARY_BUTTON_CLICK, this::handleSecondaryAction);
        mMessageEnqueuedTime = MessagesMetrics.now();
        mAreExtraHistogramsEnabled = MessageFeatureList.areExtraHistogramsEnabled();
    }

    /**
     * Show a message view on the given {@link MessageContainer}.
     *
     * @param fromIndex The initial position of the message view.
     * @param toIndex The target position of the message view.
     * @return The animator to move the message view.
     */
    @NonNull
    @Override
    public Animator show(int fromIndex, int toIndex) {
        if (mMessageBanner == null) {
            mView =
                    (MessageBannerView)
                            LayoutInflater.from(mContainer.getContext())
                                    .inflate(R.layout.message_banner_view, mContainer, false);
            mMessageBanner =
                    new MessageBannerCoordinator(
                            mView,
                            mModel,
                            mMaxTranslationSupplier,
                            mTopOffsetSupplier,
                            mContainer.getResources(),
                            () -> {
                                mDismissHandler.invoke(mModel, DismissReason.GESTURE);
                            },
                            mSwipeAnimationHandler,
                            mAutodismissDurationMs,
                            () -> {
                                mDismissHandler.invoke(mModel, DismissReason.TIMER);
                            });
        }

        // Update elevation to ensure background view is always behind the front one.
        int elevationDimen =
                toIndex == Position.FRONT
                        ? R.dimen.message_banner_elevation
                        : R.dimen.message_banner_back_elevation;
        mModel.set(
                MessageBannerProperties.ELEVATION,
                mView.getResources().getDimension(elevationDimen));
        // #show can be called multiple times when its own index is updated.
        if (mContainer.indexOfChild(mView) == -1) {
            mContainer.addMessage(mView);
        }

        mMessageShownTime = MessagesMetrics.now();
        if (toIndex == Position.FRONT) {
            mContainer.setA11yDelegate(this);
            if (mAreExtraHistogramsEnabled) {
                if (!mFullyVisibleBefore) {
                    MessagesMetrics.recordTimeToFullyShow(
                            getMessageIdentifier(), mMessageShownTime - mMessageEnqueuedTime);
                }
                MessagesMetrics.recordFullyVisible(getMessageIdentifier());
                mFullyVisibleBefore = true;
            }

            notifyVisibilityChange(true);
        } else {
            notifyVisibilityChange(false);
        }

        return mMessageBanner.show(fromIndex, toIndex, () -> MessageDimens.from(mContainer, mView));
    }

    /**
     * Hide the message view shown on the given {@link MessageContainer}.
     *
     * @param fromIndex The initial position of the message view.
     * @param toIndex The target position of the message view.
     * @param animate Whether to show animation.
     * @return The animator to move the message view.
     */
    @Nullable
    @Override
    public Animator hide(int fromIndex, int toIndex, boolean animate) {
        notifyVisibilityChange(false);
        return mMessageBanner.hide(
                fromIndex, toIndex, animate, () -> mContainer.removeMessage(mView));
    }

    /**
     * Remove message from the message queue so that the message will not be shown anymore.
     *
     * @param dismissReason The reason why message is being dismissed.
     */
    @Override
    public void dismiss(@DismissReason int dismissReason) {
        Callback<Integer> onDismissed = mModel.get(MessageBannerProperties.ON_DISMISSED);
        if (onDismissed != null) onDismissed.onResult(dismissReason);
        mMessageDismissed = true;
        if (dismissReason == DismissReason.PRIMARY_ACTION
                || dismissReason == DismissReason.SECONDARY_ACTION
                || dismissReason == DismissReason.GESTURE) {
            // Only record time to dismiss when the user explicitly dismissed the message.
            MessagesMetrics.recordTimeToAction(
                    getMessageIdentifier(),
                    dismissReason == DismissReason.GESTURE,
                    MessagesMetrics.now() - mMessageShownTime);
        }
        if (mAreExtraHistogramsEnabled) {
            if (dismissReason == DismissReason.PRIMARY_ACTION
                    || dismissReason == DismissReason.SECONDARY_ACTION
                    || dismissReason == DismissReason.GESTURE
                    || dismissReason == DismissReason.TIMER) {
                if (getOnFullyVisibleCallback() != null && !mFullyVisibleCallbackInvoked) {
                    MessagesMetrics.recordErrorFullyVisibleNotInformed(getMessageIdentifier());
                }
            }

            if (!mFullyVisibleBefore) {
                MessagesMetrics.recordDismissedWithoutFullyVisible(getMessageIdentifier());
            }
        }
    }

    @Override
    public void onA11yFocused() {
        mMessageBanner.cancelTimer();
    }

    @Override
    public void onA11yFocusCleared() {
        mMessageBanner.startTimer();
    }

    @Override
    public void onA11yDismiss() {
        mDismissHandler.invoke(mModel, DismissReason.GESTURE);
    }

    private void handlePrimaryAction(View v) {
        // Avoid running the primary action callback if the message has already been dismissed.
        if (mMessageDismissed) return;

        if (mModel.get(MessageBannerProperties.ON_PRIMARY_ACTION).get()
                == PrimaryActionClickBehavior.DISMISS_IMMEDIATELY) {
            mDismissHandler.invoke(mModel, DismissReason.PRIMARY_ACTION);
        }
    }

    private void handleSecondaryAction() {
        // Avoid running the secondary action callback if the message has already been dismissed.
        if (mMessageDismissed) return;
        mModel.get(MessageBannerProperties.ON_SECONDARY_ACTION).run();
    }

    @VisibleForTesting
    void notifyVisibilityChange(boolean fullyVisible) {
        if (!mIsFullyVisibileCallbackEnabled) return;

        var callback = getOnFullyVisibleCallback();
        if (callback == null) return;
        if (fullyVisible == mModel.get(MessageBannerProperties.IS_FULLY_VISIBLE)) return;
        mFullyVisibleCallbackInvoked = true;

        mModel.set(MessageBannerProperties.IS_FULLY_VISIBLE, fullyVisible);
        callback.onResult(fullyVisible);
    }

    private @Nullable Callback<Boolean> getOnFullyVisibleCallback() {
        if (!mModel.containsKey(MessageBannerProperties.ON_FULLY_VISIBLE)) return null;

        return mModel.get(MessageBannerProperties.ON_FULLY_VISIBLE);
    }

    @VisibleForTesting
    long getAutoDismissDuration() {
        return mAutodismissDurationMs.get();
    }

    void setMessageBannerForTesting(MessageBannerCoordinator messageBanner) {
        mMessageBanner = messageBanner;
    }

    void setViewForTesting(MessageBannerView view) {
        mView = view;
    }

    boolean getMessageDismissedForTesting() {
        return mMessageDismissed;
    }

    PropertyModel getModelForTesting() {
        return mModel;
    }

    @Override
    @MessageIdentifier
    public int getMessageIdentifier() {
        Integer messageIdentifier = mModel.get(MessageBannerProperties.MESSAGE_IDENTIFIER);
        assert messageIdentifier != null;
        return messageIdentifier;
    }
}