chromium/chrome/browser/user_education/java/src/org/chromium/chrome/browser/user_education/UserEducationHelper.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.chrome.browser.user_education;

import android.app.Activity;
import android.os.Handler;
import android.view.View;

import androidx.annotation.NonNull;

import org.chromium.base.TraceEvent;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.supplier.SupplierUtils;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.util.ChromeAccessibilityUtil;
import org.chromium.components.browser_ui.widget.highlight.ViewHighlighter;
import org.chromium.components.browser_ui.widget.highlight.ViewHighlighter.HighlightParams;
import org.chromium.components.browser_ui.widget.textbubble.TextBubble;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.feature_engagement.TriggerDetails;
import org.chromium.ui.widget.RectProvider;
import org.chromium.ui.widget.ViewRectProvider;

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

/**
 * Class that manages requests to trigger IPH's. Customizes the IPH with text bubbles, view
 * highlights, etc. based on the configuration.
 * Recipes for use:
 * 1. Create an IPH bubble anchored to a view:
 * mUserEducationHelper.requestShowIPH(new IPHCommandBuilder(myContext.getResources(),
 *                 FeatureConstants.MY_FEATURE_NAME, R.string.my_feature_iph_text,
 *                 R.string.my_feature_iph_accessibility_text)
 *                                                     .setAnchorView(myAnchorView)
 *                                                     .setCircleHighlight(true)
 *                                                     .build());
 * 2. Create an IPH bubble that does custom logic when shown and hidden
 * mUserEducationHelper.requestShowIPH(new IPHCommandBuilder(myContext.getResources(),
 *                 FeatureConstants.MY_FEATURE_NAME, R.string.my_feature_iph_text,
 *                 R.string.my_feature_iph_accessibility_text)
 *                                                     .setAnchorView(myAnchorView)
 *                                                     .setCircleHighlight(true)
 *                                                     .setOnShowCallback( ()-> doCustomShowLogic())
 *                                                     .setOnDismissCallback(() ->
 *                                                         doCustomDismissLogic())
 *                                                     .build());
 */
public class UserEducationHelper {
    private final Activity mActivity;
    private final Handler mHandler;

    private Profile mProfile;
    private List<IPHCommand> mPendingIPHCommands;
    private TextBubble mTextBubble;

    /**
     * Constructs a {@link UserEducationHelper} that is immediately available to process inbound
     * {@link IPHCommand}s.
     */
    public UserEducationHelper(
            @NonNull Activity activity, @NonNull Profile profile, Handler handler) {
        assert activity != null : "Trying to show an IPH for a null activity.";
        assert profile != null : "Trying to show an IPH with a null profile";

        mActivity = activity;
        mHandler = handler;

        setProfile(profile);
    }

    /**
     * Constructs a {@link UserEducationHelper} that will wait for a {@link Profile} to become
     * available before processing inbound {@link IPHCommand}s.
     *
     * <p>Caveat, this will only observe the first available Profile from the supplier and will keep
     * a reference to the {@link Profile#getOriginalProfile()}.
     */
    public UserEducationHelper(
            @NonNull Activity activity,
            @NonNull Supplier<Profile> profileSupplier,
            Handler handler) {
        assert activity != null : "Trying to show an IPH for a null activity.";
        assert profileSupplier != null : "Trying to show an IPH with a null profile supplier";

        mActivity = activity;
        mHandler = handler;

        SupplierUtils.waitForAll(() -> setProfile(profileSupplier.get()), profileSupplier);
    }

    private void setProfile(Profile profile) {
        assert profile != null;
        mProfile = profile.getOriginalProfile();

        if (mPendingIPHCommands != null) {
            for (IPHCommand iphCommand : mPendingIPHCommands) {
                requestShowIPH(iphCommand);
            }
            mPendingIPHCommands = null;
        }
    }

    /**
     * Requests display of the in-product help (IPH) data in @param iphCommand.
     * @see IPHCommand for a breakdown of this data.
     * Display will only occur if the feature engagement tracker for the current profile says it
     * should.
     */
    public void requestShowIPH(IPHCommand iphCommand) {
        if (iphCommand == null) return;

        if (mProfile == null) {
            if (mPendingIPHCommands == null) mPendingIPHCommands = new ArrayList<>();
            mPendingIPHCommands.add(iphCommand);
            return;
        }

        try (TraceEvent te = TraceEvent.scoped("UserEducationHelper::requestShowIPH")) {
            final Tracker tracker = TrackerFactory.getTrackerForProfile(mProfile);
            tracker.addOnInitializedCallback(success -> showIPH(tracker, iphCommand));
        }
    }

    private void showIPH(Tracker tracker, IPHCommand iphCommand) {
        // Activity was destroyed; don't show IPH.
        View anchorView = iphCommand.anchorView;
        if (mActivity == null
                || mActivity.isFinishing()
                || mActivity.isDestroyed()
                || anchorView == null) {
            iphCommand.onBlockedCallback.run();
            return;
        }

        String featureName = iphCommand.featureName;
        assert (featureName != null);

        ViewRectProvider viewRectProvider = iphCommand.viewRectProvider;
        RectProvider rectProvider =
                iphCommand.anchorRect != null ? new RectProvider(iphCommand.anchorRect) : null;
        if (viewRectProvider == null && rectProvider == null) {
            viewRectProvider = new ViewRectProvider(anchorView);
        }

        HighlightParams highlightParams = iphCommand.highlightParams;
        mTextBubble = null;
        TriggerDetails triggerDetails =
                new TriggerDetails(
                        tracker.shouldTriggerHelpUI(featureName), /* shouldShowSnooze= */ false);

        assert (triggerDetails != null);
        if (!triggerDetails.shouldTriggerIph) {
            iphCommand.onBlockedCallback.run();
            return;
        }

        // iphCommand would have been built lazily, and we would have to fetch the data that is
        // needed from this point on.
        iphCommand.fetchFromResources();

        if (iphCommand.showTextBubble) {
            String contentString = iphCommand.contentString;
            String accessibilityString = iphCommand.accessibilityText;
            assert (!contentString.isEmpty());
            assert (!accessibilityString.isEmpty());

            mTextBubble =
                    new TextBubble(
                            mActivity,
                            anchorView,
                            contentString,
                            accessibilityString,
                            !iphCommand.removeArrow,
                            viewRectProvider != null ? viewRectProvider : rectProvider,
                            ChromeAccessibilityUtil.get().isAccessibilityEnabled());
            mTextBubble.setPreferredVerticalOrientation(iphCommand.preferredVerticalOrientation);
            mTextBubble.setDismissOnTouchInteraction(iphCommand.dismissOnTouch);
            mTextBubble.addOnDismissListener(
                    () ->
                            mHandler.postDelayed(
                                    () -> {
                                        if (featureName != null) tracker.dismissed(featureName);
                                        iphCommand.onDismissCallback.run();
                                        if (highlightParams != null) {
                                            ViewHighlighter.turnOffHighlight(anchorView);
                                        }
                                        mTextBubble = null;
                                    },
                                    ViewHighlighter.IPH_MIN_DELAY_BETWEEN_TWO_HIGHLIGHTS));
            mTextBubble.setAutoDismissTimeout(iphCommand.autoDismissTimeout);

            mTextBubble.show();
        }

        if (highlightParams != null) {
            ViewHighlighter.turnOnHighlight(anchorView, highlightParams);
        }

        if (viewRectProvider != null) {
            viewRectProvider.setInsetPx(iphCommand.insetRect);
        }

        iphCommand.onShowCallback.run();
    }

    /** Dismisses the currently showing text bubble, if any. */
    public void dismissTextBubble() {
        if (mTextBubble != null) {
            mTextBubble.dismiss();
        }
    }
}