chromium/chrome/browser/tab/java/src/org/chromium/chrome/browser/tab/SadTab.java

// Copyright 2013 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.tab;

import android.content.Context;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup.LayoutParams;
import android.widget.Button;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.UserData;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.tab.Tab.LoadUrlResult;
import org.chromium.components.ui_metrics.SadTabEvent;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.ui.text.NoUnderlineClickableSpan;
import org.chromium.ui.text.SpanApplier;
import org.chromium.ui.text.SpanApplier.SpanInfo;
import org.chromium.ui.widget.ChromeBulletSpan;
import org.chromium.url.GURL;

/**
 * Represent the sad tab displayed in place of a crashed renderer. Instantiated on the first
 * |show()| request from a Tab, and destroyed together with it. TODO(crbug.com/40162422): Consider
 * moving this to its own target.
 */
public class SadTab extends EmptyTabObserver implements UserData, TabViewProvider {
    private static final Class<SadTab> USER_DATA_KEY = SadTab.class;

    private final Tab mTab;

    private View mView;

    /**
     * Counts the number of successive refreshes on the sad tab page. The count is is reset after a
     * successful page load.
     */
    private int mSadTabSuccessiveRefreshCounter;

    public static SadTab from(Tab tab) {
        SadTab sadTab = get(tab);
        if (sadTab == null) {
            sadTab = tab.getUserDataHost().setUserData(USER_DATA_KEY, new SadTab(tab));
        }
        return sadTab;
    }

    public static SadTab get(Tab tab) {
        return tab.getUserDataHost().getUserData(USER_DATA_KEY);
    }

    public static boolean isShowing(Tab tab) {
        if (tab == null || !tab.isInitialized()) return false;
        SadTab sadTab = get(tab);
        return sadTab != null ? sadTab.isShowing() : false;
    }

    @VisibleForTesting
    public SadTab(Tab tab) {
        mTab = tab;
        mTab.addObserver(this);
    }

    /** Constructs and shows a sad tab (Aw, Snap!). */
    public void show(Context context, Runnable suggestionAction, Runnable buttonAction) {
        if (mTab.getWebContents() == null) return;

        if (mView != null) {
            assert !mTab.getTabViewManager().isShowing(this);
            // SadTab was requested to show but TabContentManager may have queued it in favor
            // of a tab view of other type such as |TabViewProvider.Type.SUSPENDED_TAB| or
            // |PAINT_PREVIEW}|. Just return and wait for the SadTab's turn to show up.
            return;
        }
        mSadTabSuccessiveRefreshCounter++;
        mView =
                createView(
                        context,
                        suggestionAction,
                        buttonAction,
                        showSendFeedbackView(),
                        mTab.isIncognito());

        mTab.getTabViewManager().addTabViewProvider(this);
    }

    /**
     * @return {@code true} if we should show 'Send Feedback'.
     */
    public boolean showSendFeedbackView() {
        // If the tab has crashed twice in a row change the sad tab view to the "Send Feedback"
        // version and change the onClickListener.
        return mSadTabSuccessiveRefreshCounter >= 2;
    }

    /** Removes the sad tab view if present. */
    @VisibleForTesting
    public void removeIfPresent() {
        mTab.getTabViewManager().removeTabViewProvider(this);
        mView = null;
    }

    /**
     * @return Whether or not the sad tab is showing.
     */
    public boolean isShowing() {
        return mView != null && mTab.getTabViewManager().isShowing(this);
    }

    // TabObserver

    @Override
    public void onLoadUrl(Tab tab, LoadUrlParams params, LoadUrlResult loadUrlResult) {
        removeIfPresent();
    }

    @Override
    public void onPageLoadStarted(Tab tab, GURL url) {
        removeIfPresent();
    }

    @Override
    public void onPageLoadFinished(Tab tab, GURL url) {
        // Reset the succressiveRefresh counter after successfully loading a page.
        mSadTabSuccessiveRefreshCounter = 0;
        removeIfPresent();
    }

    // UserData

    @Override
    public void destroy() {
        mTab.removeObserver(this);
    }

    @Override
    public @ColorInt int getBackgroundColor(Context context) {
        return context.getColor(R.color.baseline_neutral_90);
    }

    /**
     * @param context Context this sad tab is shown with.
     * @param suggestionAction {@link Runnable} to be executed when user clicks "try these
     *     suggestions".
     * @param buttonAction {@link Runnable} to be executed when the button is pressed. (e.g.,
     *     refreshing the page or sending feedback)
     * @param showSendFeedbackView Whether to show the "send feedback" version of the Sad Tab view.
     * @param isIncognito Whether the Sad Tab view is being showin in an incognito tab.
     * @return A {@link View} instance which is used in place of a crashed renderer.
     */
    protected View createView(
            Context context,
            final Runnable suggestionAction,
            Runnable buttonAction,
            boolean showSendFeedbackView,
            boolean isIncognito) {
        // Inflate Sad tab and initialize.
        LayoutInflater inflater =
                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View sadTabView = inflater.inflate(R.layout.sad_tab, null);
        sadTabView.setLayoutParams(
                new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));

        TextView titleText = (TextView) sadTabView.findViewById(R.id.sad_tab_title);
        int titleTextId =
                showSendFeedbackView ? R.string.sad_tab_reload_title : R.string.sad_tab_title;
        titleText.setText(titleTextId);

        if (showSendFeedbackView) intializeSuggestionsViews(context, sadTabView, isIncognito);

        TextView messageText = (TextView) sadTabView.findViewById(R.id.sad_tab_message);
        messageText.setText(getHelpMessage(context, suggestionAction, showSendFeedbackView));
        messageText.setMovementMethod(LinkMovementMethod.getInstance());

        Button button = (Button) sadTabView.findViewById(R.id.sad_tab_button);
        int buttonTextId =
                showSendFeedbackView
                        ? R.string.sad_tab_send_feedback_label
                        : R.string.sad_tab_reload_label;
        button.setText(buttonTextId);
        button.setOnClickListener(
                new OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        recordEvent(showSendFeedbackView, SadTabEvent.BUTTON_CLICKED);
                        buttonAction.run();
                    }
                });

        recordEvent(showSendFeedbackView, SadTabEvent.DISPLAYED);

        return sadTabView;
    }

    /**
     * Construct and return help message to be displayed on R.id.sad_tab_message.
     * @param context Context of the resulting Sad Tab view. This is needed to load the strings.
     * @param suggestionAction Action to be executed when user clicks "try these suggestions"
     *                         or "learn more".
     * @return Help message to be displayed on R.id.sad_tab_message.
     */
    private static CharSequence getHelpMessage(
            Context context, final Runnable suggestionAction, final boolean showSendFeedback) {
        NoUnderlineClickableSpan linkSpan =
                new NoUnderlineClickableSpan(
                        context,
                        (view) -> {
                            recordEvent(showSendFeedback, SadTabEvent.HELP_LINK_CLICKED);
                            suggestionAction.run();
                        });

        if (showSendFeedback) {
            SpannableString learnMoreLink =
                    new SpannableString(context.getString(R.string.sad_tab_reload_learn_more));
            learnMoreLink.setSpan(linkSpan, 0, learnMoreLink.length(), 0);
            return learnMoreLink;
        } else {
            String helpMessage =
                    context.getString(R.string.sad_tab_message)
                            + "\n\n"
                            + context.getString(R.string.sad_tab_suggestions);
            return SpanApplier.applySpans(helpMessage, new SpanInfo("<link>", "</link>", linkSpan));
        }
    }

    /**
     * Initializes the TextViews that display tips for handling repeated crashes.
     * @param context Context of the resulting Sad Tab view.
     * @param sadTabView The parent Sad Tab view that contains the TextViews.
     * @param isIncognito Whether the Sad Tab view is being showing in an incognito tab.
     */
    private static void intializeSuggestionsViews(
            Context context, View sadTabView, boolean isIncognito) {
        TextView suggestionsTitle =
                (TextView) sadTabView.findViewById(R.id.sad_tab_suggestions_title);
        suggestionsTitle.setVisibility(View.VISIBLE);
        suggestionsTitle.setText(R.string.sad_tab_reload_try);

        TextView suggestions = (TextView) sadTabView.findViewById(R.id.sad_tab_suggestions);
        suggestions.setVisibility(View.VISIBLE);

        SpannableStringBuilder spannableString = new SpannableStringBuilder();
        if (!isIncognito) {
            spannableString
                    .append(generateBulletedString(context, R.string.sad_tab_reload_incognito))
                    .append("\n");
        }
        spannableString
                .append(generateBulletedString(context, R.string.sad_tab_reload_restart_browser))
                .append("\n")
                .append(generateBulletedString(context, R.string.sad_tab_reload_restart_device))
                .append("\n");
        suggestions.setText(spannableString);
    }

    /**
     * Generates a bulleted {@link SpannableString}.
     * @param context The {@link Context} used to retrieve the String.
     * @param stringResId The resource id of the String to bullet.
     * @return A {@link SpannableString} with a bullet in front of the provided String.
     */
    private static SpannableString generateBulletedString(Context context, int stringResId) {
        SpannableString bullet = new SpannableString(context.getString(stringResId));
        bullet.setSpan(new ChromeBulletSpan(context), 0, bullet.length(), 0);
        return bullet;
    }

    /**
     * Records enumerated histograms for {@link SadTabEvent}.
     * @param sendFeedbackView Whether the event is for the "send feedback" version of the Sad Tab.
     * @param event The {@link SadTabEvent} to record.
     */
    private static void recordEvent(boolean sendFeedbackView, int event) {
        if (sendFeedbackView) {
            RecordHistogram.recordEnumeratedHistogram(
                    "Tabs.SadTab.Feedback.Event", event, SadTabEvent.MAX_SAD_TAB_EVENT);
        } else {
            RecordHistogram.recordEnumeratedHistogram(
                    "Tabs.SadTab.Reload.Event", event, SadTabEvent.MAX_SAD_TAB_EVENT);
        }
    }

    // Bare minimum set up so |isShowing| returns true.
    public static void initForTesting(Tab tab, SadTab sadTab) {
        tab.getUserDataHost().setUserData(USER_DATA_KEY, sadTab);
    }

    @Override
    public int getTabViewProviderType() {
        return Type.SAD_TAB;
    }

    @Override
    public View getView() {
        return mView;
    }
}