chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/HiddenTabHolder.java

// Copyright 2018 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.customtabs;

import android.content.Intent;
import android.os.Bundle;
import android.text.TextUtils;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.browser.customtabs.CustomTabsSessionToken;

import org.chromium.base.Callback;
import org.chromium.base.TraceEvent;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.WarmupManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.RedirectHandlerTabHelper;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.common.Referrer;
import org.chromium.network.mojom.ReferrerPolicy;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.Origin;

/**
 * Holds a hidden tab which may be used to preload pages before a CustomTabActivity is launched.
 *
 * Lifecycle: 1:1 relationship between this and {@link CustomTabsConnection}.
 * Thread safety: Only access on UI Thread.
 * Native: This class needs native to be loaded (since it creates Tabs).
 */
public class HiddenTabHolder {
    /** Holds the parameters for the current hidden tab speculation. */
    @VisibleForTesting
    static final class SpeculationParams {
        public final CustomTabsSessionToken session;
        public final String url;
        public final Tab tab;
        public final String referrer;

        private SpeculationParams(
                CustomTabsSessionToken session, String url, Tab tab, String referrer) {
            this.session = session;
            this.url = url;
            this.tab = tab;
            this.referrer = referrer;
        }
    }

    private class HiddenTabObserver extends EmptyTabObserver {
        // This WindowAndroid is "owned" by the Tab and should be destroyed when it is no longer
        // needed by the Tab or when the Tab is destroyed.
        private WindowAndroid mOwnedWindowAndroid;

        public HiddenTabObserver(WindowAndroid ownedWindowAndroid) {
            mOwnedWindowAndroid = ownedWindowAndroid;
        }

        @Override
        public void onCrash(Tab tab) {
            destroyHiddenTab(null);
        }

        @Override
        public void onDestroyed(Tab tab) {
            destroyOwnedWindow(tab);
        }

        @Override
        public void onActivityAttachmentChanged(Tab tab, WindowAndroid window) {
            destroyOwnedWindow(tab);
        }

        private void destroyOwnedWindow(Tab tab) {
            assert mOwnedWindowAndroid != null;
            mOwnedWindowAndroid.destroy();
            mOwnedWindowAndroid = null;
            tab.removeObserver(this);
        }
    }

    @Nullable private SpeculationParams mSpeculation;

    /**
     * Creates a hidden tab and initiates a navigation.
     *
     * @param tabCreatedCallback Callback run with the tab that is created. This is run before the
     *     url is loaded.
     * @param session The {@link CustomTabsSessionToken} for the Tab to be associated with.
     * @param profile The Profile the tab is associated with.
     * @param clientManager The {@link ClientManager} to get referrer information and link
     *     PostMessage.
     * @param url The URL to load into the Tab.
     * @param extras Extras to be passed that may contain referrer information.
     * @param webContents The {@link WebContents} to use in the hidden tab. If null the default is
     *     used.
     */
    void launchUrlInHiddenTab(
            Callback<Tab> tabCreatedCallback,
            CustomTabsSessionToken session,
            Profile profile,
            ClientManager clientManager,
            String url,
            @Nullable Bundle extras,
            @Nullable WebContents webContents) {
        assert mSpeculation == null;
        Intent extrasIntent = new Intent();
        if (extras != null) extrasIntent.putExtras(extras);

        // Ensures no Browser.EXTRA_HEADERS were in the Intent.
        if (IntentHandler.getExtraHeadersFromIntent(extrasIntent) != null) return;

        WarmupManager warmupManager = WarmupManager.getInstance();
        if (warmupManager.hasSpareTab(profile) && webContents != null) {
            warmupManager.destroySpareTab();
        }
        warmupManager.createRegularSpareTab(profile, webContents);
        // In case creating the tab fails for some reason.
        if (!warmupManager.hasSpareTab(profile)) return;
        Tab tab =
                warmupManager.takeSpareTab(
                        profile, TabLaunchType.FROM_SPECULATIVE_BACKGROUND_CREATION);

        tabCreatedCallback.onResult(tab);

        HiddenTabObserver observer = new HiddenTabObserver(tab.getWindowAndroid());
        tab.addObserver(observer);

        // Updating post message as soon as we have a valid WebContents.
        clientManager.resetPostMessageHandlerForSession(session, tab.getWebContents());

        LoadUrlParams loadParams = new LoadUrlParams(url);
        String referrer = IntentHandler.getReferrerUrlIncludingExtraHeaders(extrasIntent);
        if (referrer == null && clientManager.getDefaultReferrerForSession(session) != null) {
            referrer = clientManager.getDefaultReferrerForSession(session).getUrl();
        }
        if (referrer == null) referrer = "";
        if (!referrer.isEmpty()) {
            loadParams.setReferrer(new Referrer(referrer, ReferrerPolicy.DEFAULT));
        }
        // The sender of an intent can't be trusted, so we navigate from an opaque Origin to
        // avoid sending same-site cookies.
        loadParams.setInitiatorOrigin(Origin.createOpaqueOrigin());

        loadParams.setTransitionType(PageTransition.LINK | PageTransition.FROM_API);
        RedirectHandlerTabHelper.getOrCreateHandlerFor(tab).setIsPrefetchLoadForIntent(true);
        mSpeculation = new SpeculationParams(session, url, tab, referrer);
        mSpeculation.tab.loadUrl(loadParams);
    }

    /**
     * Returns the preloaded {@link Tab} if it matches the given |url| and |referrer|. Null if no
     * such {@link Tab}. If a {@link Tab} is preloaded but it does not match, it is discarded.
     *
     * @param session The Binder object identifying a session the hidden tab was created for.
     * @param ignoreFragments Whether to ignore fragments while matching the url.
     * @param url The URL the tab is for.
     * @param referrer The referrer to use for |url|.
     * @return The hidden tab, or null.
     */
    @Nullable
    Tab takeHiddenTab(
            @Nullable CustomTabsSessionToken session,
            boolean ignoreFragments,
            String url,
            @Nullable String referrer) {
        try (TraceEvent e = TraceEvent.scoped("CustomTabsConnection.takeHiddenTab")) {
            if (mSpeculation == null || session == null) return null;
            if (!session.equals(mSpeculation.session)) return null;

            Tab tab = mSpeculation.tab;
            String speculatedUrl = mSpeculation.url;
            String speculationReferrer = mSpeculation.referrer;

            mSpeculation = null;

            boolean urlsMatch =
                    ignoreFragments
                            ? UrlUtilities.urlsMatchIgnoringFragments(speculatedUrl, url)
                            : TextUtils.equals(speculatedUrl, url);

            if (referrer == null) referrer = "";

            if (urlsMatch && TextUtils.equals(speculationReferrer, referrer)) {
                return tab;
            } else {
                tab.destroy();
                return null;
            }
        }
    }

    /** Cancels the speculation for a given session, or any session if null. */
    void destroyHiddenTab(@Nullable CustomTabsSessionToken session) {
        if (mSpeculation == null) return;
        if (session != null && !session.equals(mSpeculation.session)) return;

        mSpeculation.tab.destroy();
        mSpeculation = null;
    }

    /** Gets the url of the current hidden tab, if it exists. */
    @Nullable
    String getSpeculatedUrl(CustomTabsSessionToken session) {
        if (mSpeculation == null || !mSpeculation.session.equals(session)) {
            return null;
        }
        return mSpeculation.url;
    }

    /** Returns whether there currently is a hidden tab. */
    boolean hasHiddenTab() {
        return mSpeculation != null;
    }

    public Tab getHiddenTabForTesting() {
        return mSpeculation != null ? mSpeculation.tab : null;
    }

    @Nullable
    SpeculationParams getSpeculationParamsForTesting() {
        return mSpeculation;
    }
}