chromium/chrome/android/java/src/org/chromium/chrome/browser/WarmupManager.java

// Copyright 2015 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;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.content.res.Resources.Theme;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.util.ArraySet;
import android.util.DisplayMetrics;
import android.view.ContextThemeWrapper;
import android.view.InflateException;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.FrameLayout;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.asynclayoutinflater.appcompat.AsyncAppCompatFactory;
import androidx.core.content.res.ResourcesCompat;

import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;

import org.chromium.base.BuildInfo;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.TerminationStatus;
import org.chromium.base.ThreadUtils;
import org.chromium.base.TraceEvent;
import org.chromium.base.library_loader.LibraryLoader;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.app.tab_activity_glue.ReparentingTask;
import org.chromium.chrome.browser.content.WebContentsFactory;
import org.chromium.chrome.browser.crash.ChromePureJavaExceptionReporter;
import org.chromium.chrome.browser.customtabs.CustomTabDelegateFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabBuilder;
import org.chromium.chrome.browser.tab.TabDelegateFactory;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabUtils;
import org.chromium.chrome.browser.toolbar.ControlContainer;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.net.NetId;
import org.chromium.ui.LayoutInflaterUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.display.DisplayUtil;
import org.chromium.url.GURL;
import org.chromium.url.Origin;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * This class is a singleton that holds utilities for warming up Chrome and prerendering urls
 * without creating the Activity.
 *
 * This class is not thread-safe and must only be used on the UI thread.
 */
public class WarmupManager {
    private static final String TAG = "WarmupManager";

    /**
     * Observes spare WebContents deaths. In case of death, records stats, and cleanup the objects.
     */
    private class RenderProcessGoneObserver extends WebContentsObserver {
        @Override
        public void primaryMainFrameRenderProcessGone(@TerminationStatus int terminationStatus) {
            destroySpareWebContentsInternal();
        }
    }

    /** Records stats, observes crashes, and cleans up spareTab object. */
    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
        // Invoked when tab crashes, or when the associated renderer process is killed.
        public void onCrash(Tab tab) {
            mSpareTabFinalStatus = SpareTabFinalStatus.TAB_CRASHED;
            destroySpareTabInternal();
        }

        @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);
        }
    }

    /** Context wrapper that routes APIs via Activity context once it's available. */
    private static class CctContextWrapper extends ContextThemeWrapper {
        Context mActivityContext;

        public CctContextWrapper(Context base, int themeResId) {
            super(base, themeResId);
        }

        @Override
        public void startActivity(Intent intent, @Nullable Bundle options) {
            // Starting activities generally requires an Activity context.
            // https://crbug.com/334755104
            Context target = mActivityContext != null ? mActivityContext : getBaseContext();
            target.startActivity(intent, options);
        }
    }

    @SuppressLint("StaticFieldLeak")
    private static WarmupManager sWarmupManager;

    private final Set<String> mDnsRequestsInFlight;
    private final Map<String, Profile> mPendingPreconnectWithProfile;

    private int mToolbarContainerId;
    private ViewGroup mMainView;
    @VisibleForTesting WebContents mSpareWebContents;
    private RenderProcessGoneObserver mObserver;
    private boolean mIsCCTPrewarmTabEnabled;

    // Stores a prebuilt tab. To load a URL, this can be used if available instead of creating one
    // from scratch.
    @VisibleForTesting Tab mSpareTab;

    /**
     * Represents various states of spareTab.
     *
     * These values are persisted to logs. Entries should not be renumbered and
     * numeric values should never be reused. See tools/metrics/histograms/enums.xml.
     */
    @IntDef({
        SpareTabFinalStatus.TAB_CREATED_BUT_NOT_USED,
        SpareTabFinalStatus.TAB_CREATION_IN_PROGRESS,
        SpareTabFinalStatus.TAB_USED,
        SpareTabFinalStatus.TAB_CRASHED,
        SpareTabFinalStatus.TAB_DESTROYED,
        SpareTabFinalStatus.NUM_ENTRIES
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface SpareTabFinalStatus {
        int TAB_CREATED_BUT_NOT_USED = 0;
        int TAB_CREATION_IN_PROGRESS = 1;
        int TAB_USED = 2;
        int TAB_CRASHED = 3;
        int TAB_DESTROYED = 4;
        int NUM_ENTRIES = 5;
    }

    @SpareTabFinalStatus int mSpareTabFinalStatus;

    /**
     * Records the spareTab final status.
     * @param status Status to be recorded in the enumerated histogram.
     */
    private void recordSpareTabFinalStatusHistogram(@SpareTabFinalStatus int status) {
        RecordHistogram.recordEnumeratedHistogram(
                "Android.SpareTab.FinalStatus", status, SpareTabFinalStatus.NUM_ENTRIES);
    }

    /** Destroys the spare Tab if there is one and sets mSpareTab to null. */
    public void destroySpareTab() {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.destroySpareTab")) {
            ThreadUtils.assertOnUiThread();

            mSpareTabFinalStatus = SpareTabFinalStatus.TAB_DESTROYED;
            destroySpareTabInternal();
        }
    }

    private void destroySpareTabInternal() {
        // Don't do anything if the spare tab doesn't exist.
        if (mSpareTab == null) return;

        // Record the SpareTabFinalStatus once its destroyed.
        recordSpareTabFinalStatusHistogram(mSpareTabFinalStatus);

        mSpareTab.destroy();
        mSpareTab = null;
    }

    /**
     * Creates and initializes a Regular (non-Incognito) spare Tab, to be used for a subsequent
     * navigation.
     *
     * <p>This creates a WebContents and initializes renderer if SPARE_TAB_INITIALIZE_RENDERER is
     * true. Can be called multiple times, and must be called from the UI thread.
     *
     * <p>The tab's launch type will be set when the tab is used.
     */
    public void createRegularSpareTab(Profile profile) {
        createRegularSpareTab(profile, /* webContents= */ null);
    }

    /**
     * Creates and initializes a Regular (non-Incognito) spare Tab, to be used for a subsequent
     * navigation.
     *
     * <p>This creates a WebContents and initializes renderer if SPARE_TAB_INITIALIZE_RENDERER is
     * true. Can be called multiple times, and must be called from the UI thread.
     *
     * <p>The tab's launch type will be set when the tab is used.
     *
     * <p>* @param webContents The {@link WebContents} to use in the tab. If null the default is
     * used.
     */
    public void createRegularSpareTab(Profile profile, @Nullable WebContents webContents) {
        ThreadUtils.assertOnUiThread();
        assert !profile.isOffTheRecord();
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.createSpareTab")) {
            mSpareTabFinalStatus = SpareTabFinalStatus.TAB_CREATION_IN_PROGRESS;

            // Ensure native is initialized before creating spareTab.
            assert LibraryLoader.getInstance().isInitialized();

            if (mSpareTab != null) return;

            // Build a spare detached tab.
            Tab spareTab = buildDetachedSpareTab(profile, webContents);

            mSpareTab = spareTab;
            assert mSpareTab != null : "Building a spare detached tab shouldn't return null.";

            mSpareTabFinalStatus = SpareTabFinalStatus.TAB_CREATED_BUT_NOT_USED;
        }

        if (mSpareTab != null) {
            mSpareTab.addObserver(new HiddenTabObserver(mSpareTab.getWindowAndroid()));
        }
    }

    /**
     * Creates an instance of a {@link Tab} that is fully detached from any activity.
     *
     * <p>Also performs general tab initialization as well as detached specifics.
     *
     * @param webContents The {@link WebContents} to use in the tab. If null the default is used.
     * @return The newly created and initialized spare tab.
     *     <p>TODO(crbug.com/40255340): Adapt this method to create other tabs.
     */
    private Tab buildDetachedSpareTab(Profile profile, @Nullable WebContents webContents) {
        Context context = ContextUtils.getApplicationContext();

        // These are effectively unused as they will be set when finishing reparenting.
        TabDelegateFactory delegateFactory = CustomTabDelegateFactory.createEmpty();
        WindowAndroid window = new WindowAndroid(context);

        // TODO(crbug.com/40174356): Set isIncognito flag here if spare tabs are allowed for
        // incognito mode.
        // Creates a tab with renderer initialized for spareTab. See https://crbug.com/1412572.
        Tab tab =
                TabBuilder.createLiveTab(profile, true)
                        .setWindow(window)
                        .setLaunchType(TabLaunchType.UNSET)
                        .setDelegateFactory(delegateFactory)
                        .setInitiallyHidden(true)
                        .setInitializeRenderer(true)
                        .setWebContents(webContents)
                        .build();

        // Resize the webContents to avoid expensive post load resize when attaching the tab.
        Rect bounds = TabUtils.estimateContentSize(context);
        int width = bounds.right - bounds.left;
        int height = bounds.bottom - bounds.top;
        tab.getWebContents().setSize(width, height);

        // Reparent the tab to detach it from the current activity.
        ReparentingTask.from(tab).detach();
        return tab;
    }

    /**
     * Returns the spare Tab. Must only be called when a spare tab is available for |profile|.
     *
     * @param profile the profile associated with the spare Tab.
     * @param type TabLaunchType of the requested tab.
     * @return the spare Tab.
     */
    public Tab takeSpareTab(Profile profile, @TabLaunchType int type) {
        ThreadUtils.assertOnUiThread();
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.takeSpareTab")) {
            if (mSpareTab.getProfile() != profile) {
                throw new RuntimeException("Attempted to take the tab from another profile.");
            }

            Tab spareTab = mSpareTab;
            mSpareTab = null;

            spareTab.setTabLaunchType(type);
            mSpareTabFinalStatus = SpareTabFinalStatus.TAB_USED;

            // Record the SpareTabFinalStatus once its used.
            recordSpareTabFinalStatusHistogram(mSpareTabFinalStatus);
            return spareTab;
        }
    }

    /**
     * @return Whether a spare tab is available for the given profile.
     */
    public boolean hasSpareTab(Profile profile) {
        if (mSpareTab == null) return false;
        return mSpareTab.getProfile() == profile;
    }

    /**
     * @param tab Tab to compare with SpareTab with.
     *
     * @return Returns true if tab is same as spare tab.
     */
    public boolean isSpareTab(Tab tab) {
        if (mSpareTab == null) return false;

        assert mSpareTab.isHidden() : "Spare tab is not hidden";
        return mSpareTab == tab;
    }

    /** Removes the singleton instance for the WarmupManager for testing. */
    public static void deInitForTesting() {
        sWarmupManager = null;
    }

    /**
     * @return The singleton instance for the WarmupManager, creating one if necessary.
     */
    public static WarmupManager getInstance() {
        ThreadUtils.assertOnUiThread();
        if (sWarmupManager == null) sWarmupManager = new WarmupManager();
        return sWarmupManager;
    }

    private WarmupManager() {
        mDnsRequestsInFlight = new HashSet<>();
        mPendingPreconnectWithProfile = new HashMap<>();
    }

    /**
     * Inflates and constructs the view hierarchy that the app will use.
     * @param baseContext The base context to use for creating the ContextWrapper.
     * @param toolbarContainerId Id of the toolbar container.
     * @param toolbarId The toolbar's layout ID.
     */
    public void initializeViewHierarchy(
            Context baseContext, int toolbarContainerId, int toolbarId) {
        ThreadUtils.assertOnUiThread();
        if (mMainView != null && mToolbarContainerId == toolbarContainerId) return;

        CctContextWrapper context =
                new CctContextWrapper(
                        applyContextOverrides(baseContext), ActivityUtils.getThemeId());
        applyThemeOverlays(context);

        mMainView = inflateViewHierarchy(context, toolbarContainerId, toolbarId);
        mToolbarContainerId = toolbarContainerId;
    }

    @VisibleForTesting
    static Context applyContextOverrides(Context baseContext) {
        // Scale up the UI for the base Context on automotive
        if (BuildInfo.getInstance().isAutomotive) {
            Configuration config = new Configuration();
            DisplayUtil.scaleUpConfigurationForAutomotive(baseContext, config);
            return baseContext.createConfigurationContext(config);
        }

        return baseContext;
    }

    static void applyThemeOverlays(Context context) {
        // TODO(twellington): Look at improving code sharing with ChromeBaseAppCompatActivity
        // if the number of these overlays grows. The two below are experimental / are planned to be
        // removed by mid 2025 or sooner.
        if (ChromeFeatureList.sAndroidElegantTextHeight.isEnabled()) {
            int elegantTextHeightOverlay = R.style.ThemeOverlay_BrowserUI_ElegantTextHeight;
            context.getTheme().applyStyle(elegantTextHeightOverlay, true);
        }

        if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU
                && ChromeFeatureList.sAndroidGoogleSansText.isEnabled()) {
            int defaultFontFamilyOverlay =
                    ChromeBaseAppCompatActivity.DEFAULT_FONT_FAMILY_TESTING.getValue()
                            ? R.style.ThemeOverlay_BrowserUI_DevTestingDefaultFontFamilyThemeOverlay
                            : R.style.ThemeOverlay_BrowserUI_DefaultFontFamilyThemeOverlay;
            context.getTheme().applyStyle(defaultFontFamilyOverlay, true);
        }
    }

    /**
     * Inflates and constructs the view hierarchy that the app will use. Calls to this are not
     * restricted to the UI thread.
     *
     * @param context The context to use for inflation.
     * @param toolbarContainerId Id of the toolbar container.
     * @param toolbarId The toolbar's layout ID.
     */
    private static ViewGroup inflateViewHierarchy(
            CctContextWrapper context, int toolbarContainerId, int toolbarId) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.inflateViewHierarchy")) {
            FrameLayout contentHolder = new FrameLayout(context);
            var layoutInflater = LayoutInflater.from(context);
            layoutInflater.setFactory2(new AsyncAppCompatFactory());
            ViewGroup mainView =
                    (ViewGroup)
                            LayoutInflaterUtils.inflate(
                                    layoutInflater, R.layout.main, contentHolder);
            if (toolbarContainerId != ActivityUtils.NO_RESOURCE_ID) {
                ViewStub stub = mainView.findViewById(R.id.control_container_stub);
                stub.setLayoutResource(toolbarContainerId);
                stub.inflate();
            }
            // It cannot be assumed that the result of toolbarContainerStub.inflate() will be
            // the control container since it may be wrapped in another view.
            ControlContainer controlContainer = mainView.findViewById(R.id.control_container);

            if (toolbarId != ActivityUtils.NO_RESOURCE_ID && controlContainer != null) {
                controlContainer.initWithToolbar(toolbarId);
            }
            return mainView;
        } catch (InflateException e) {
            // Warmup manager is only a performance improvement. If inflation failed, it will be
            // redone when the CCT is actually launched using an activity context. So, swallow
            // exceptions here to improve resilience. See https://crbug.com/606715.
            Log.e(TAG, "Inflation exception.", e);
            // An exception caught here may indicate a real bug in production code. We report the
            // exceptions to monitor any spikes or stacks that point to Chrome code.
            Throwable throwable =
                    new Throwable(
                            "This is not a crash. See https://crbug.com/1259276 for details.", e);
            ChromePureJavaExceptionReporter.reportJavaException(throwable);
            return null;
        }
    }

    /**
     * Transfers all the children in the local view hierarchy {@link #mMainView} to the given
     * ViewGroup {@param contentView} as child.
     *
     * @param contentView The parent ViewGroup to use for the transfer.
     */
    public void transferViewHierarchyTo(ViewGroup contentView) {
        ThreadUtils.assertOnUiThread();
        ViewGroup from = mMainView;
        Set<Theme> rebasedThemes = new ArraySet<Theme>(from.getChildCount());
        mMainView = null;
        if (from == null) return;
        ((CctContextWrapper) from.getContext()).mActivityContext = contentView.getContext();
        while (from.getChildCount() > 0) {
            View currentChild = from.getChildAt(0);
            from.removeView(currentChild);
            contentView.addView(currentChild);
            // Purge any previously cached resources and ensure the Theme is rebased to match
            // the Theme of the view hierarchy the reused views are attached to.
            var theme = currentChild.getContext().getTheme();
            if (!rebasedThemes.contains(theme)) {
                ResourcesCompat.ThemeCompat.rebase(theme);
                ResourcesCompat.clearCachesForTheme(theme);
                rebasedThemes.add(theme);
            }
        }
    }

    /**
     * @param toolbarContainerId Toolbare container ID.
     * @param context Context in which the CustomTab is launched.
     * @return Whether a pre-built view hierarchy of compatible metrics exists
     *     for the given toolbarContainerId.
     */
    public boolean hasViewHierarchyWithToolbar(int toolbarContainerId, Context context) {
        ThreadUtils.assertOnUiThread();
        if (mMainView == null || mToolbarContainerId != toolbarContainerId) {
            return false;
        }
        DisplayMetrics preDm = mMainView.getContext().getResources().getDisplayMetrics();
        DisplayMetrics curDm = context.getResources().getDisplayMetrics();
        // If following displayMetrics params don't match, toolbar is being shown on a display
        // incompatible with the one it was built with, which may result in a view of a wrong
        // height. Return false to have it re-inflated with the right context.
        return preDm.xdpi == curDm.xdpi && preDm.ydpi == curDm.ydpi;
    }

    /** Clears the inflated view hierarchy. */
    public void clearViewHierarchy() {
        ThreadUtils.assertOnUiThread();
        mMainView = null;
    }

    /**
     * Launches a background DNS query for a given URL.
     *
     * @param url URL from which the domain to query is extracted.
     */
    private void prefetchDnsForUrlInBackground(final String url) {
        mDnsRequestsInFlight.add(url);
        new AsyncTask<Void>() {
            @Override
            protected Void doInBackground() {
                try (TraceEvent e =
                        TraceEvent.scoped("WarmupManager.prefetchDnsForUrlInBackground")) {
                    InetAddress.getByName(new URL(url).getHost());
                } catch (MalformedURLException e) {
                    // We don't do anything with the result of the request, it
                    // is only here to warm up the cache, thus ignoring the
                    // exception is fine.
                } catch (UnknownHostException e) {
                    // As above.
                }
                return null;
            }

            @Override
            protected void onPostExecute(Void result) {
                mDnsRequestsInFlight.remove(url);
                if (mPendingPreconnectWithProfile.containsKey(url)) {
                    Profile profile = mPendingPreconnectWithProfile.get(url);
                    mPendingPreconnectWithProfile.remove(url);
                    maybePreconnectUrlAndSubResources(profile, url);
                }
            }
        }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    /** Launches a background DNS query for a given URL.
     *
     * @param context The Application context.
     * @param url URL from which the domain to query is extracted.
     */
    public void maybePrefetchDnsForUrlInBackground(Context context, String url) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.maybePrefetchDnsForUrlInBackground")) {
            ThreadUtils.assertOnUiThread();
            prefetchDnsForUrlInBackground(url);
        }
    }

    /**
     * Starts asynchronous initialization of the preconnect predictor.
     *
     * Without this call, |maybePreconnectUrlAndSubresources()| will not use a database of origins
     * to connect to, unless the predictor has already been initialized in another way.
     *
     * @param profile The profile to use for the predictor.
     */
    public static void startPreconnectPredictorInitialization(Profile profile) {
        try (TraceEvent e =
                TraceEvent.scoped("WarmupManager.startPreconnectPredictorInitialization")) {
            ThreadUtils.assertOnUiThread();
            WarmupManagerJni.get().startPreconnectPredictorInitialization(profile);
        }
    }

    /** Asynchronously preconnects to a given URL if the data reduction proxy is not in use.
     *
     * @param profile The profile to use for the preconnection.
     * @param url The URL we want to preconnect to.
     */
    public void maybePreconnectUrlAndSubResources(Profile profile, String url) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.maybePreconnectUrlAndSubResources")) {
            ThreadUtils.assertOnUiThread();

            Uri uri = Uri.parse(url);
            if (uri == null) return;
            String scheme = uri.normalizeScheme().getScheme();
            if (!UrlConstants.HTTP_SCHEME.equals(scheme)
                    && !UrlConstants.HTTPS_SCHEME.equals(scheme)) {
                return;
            }

            // If there is already a DNS request in flight for this URL, then the preconnection will
            // start by issuing a DNS request for the same domain, as the result is not cached.
            // However, such a DNS request has already been sent from this class, so it is better to
            // wait for the answer to come back before preconnecting. Otherwise, the preconnection
            // logic will wait for the result of the second DNS request, which should arrive after
            // the result of the first one. Note that we however need to wait for the main thread to
            // be available in this case, since the preconnection will be sent from
            // AsyncTask.onPostExecute(), which may delay it.
            if (mDnsRequestsInFlight.contains(url)) {
                // Note that if two requests come for the same URL with two different profiles, the
                // last one will win.
                mPendingPreconnectWithProfile.put(url, profile);
            } else {
                WarmupManagerJni.get().preconnectUrlAndSubresources(profile, url);
            }
        }
    }

    /**
     * Request the browser to start navigational prefetch to the page that will be used for future
     * navigations.
     *
     * @param url The url to be prefetched for future navigations.
     * @param usePrefetchProxy The flag whether the private prefetch proxy is used in requested
     *     prefetch.
     * @param verifiedSourceOrigin The origin that prefetch is requested from. Currently, this is
     *     always null.
     */
    public void startPrefetchFromCCT(
            String url, boolean usePrefetchProxy, @Nullable String verifiedSourceOrigin) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.startPrefetchFromCCT")) {
            ThreadUtils.assertOnUiThread();
            if (!ChromeFeatureList.sCctNavigationalPrefetch.isEnabled()) {
                Log.e(TAG, "Prefetch failed because CCTNavigationalPrefetch is not enabled.");
                return;
            }

            WebContents webContents = null;
            if (ChromeFeatureList.isEnabled(ChromeFeatureList.CCT_PREWARM_TAB)
                    && mSpareTab != null) {
                webContents = mSpareTab.getWebContents();
            } else {
                webContents = mSpareWebContents;
            }

            if (webContents == null) {
                Log.e(
                        TAG,
                        "Prefetch failed because spare WebContents is null. warmup() is required"
                                + " beforehand.");
                return;
            }
            final GURL gurl = new GURL(url);
            Origin origin = Origin.createOpaqueOrigin();
            if (verifiedSourceOrigin != null) {
                origin = Origin.create(new GURL(verifiedSourceOrigin));
            }
            WarmupManagerJni.get()
                    .startPrefetchFromCCT(webContents, gurl, usePrefetchProxy, origin);
        }
    }

    /**
     * Creates and initializes a spare WebContents, to be used in a subsequent navigation.
     *
     * <p>This creates a renderer that is suitable for any navigation. It can be picked up by any
     * tab. Can be called multiple times, and must be called from the UI thread.
     */
    public void createSpareWebContents(Profile profile) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.createSpareWebContents")) {
            ThreadUtils.assertOnUiThread();
            if (!LibraryLoader.getInstance().isInitialized() || mSpareWebContents != null) return;

            mSpareWebContents =
                    new WebContentsFactory()
                            .createWebContentsWithWarmRenderer(
                                    profile,
                                    /* initiallyHidden= */ true,
                                    /* targetNetwork= */ NetId.INVALID);
            mObserver = new RenderProcessGoneObserver();
            mSpareWebContents.addObserver(mObserver);
        }
    }

    /** Destroys the spare WebContents if there is one. */
    public void destroySpareWebContents() {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.destroySpareWebContents")) {
            ThreadUtils.assertOnUiThread();
            if (mSpareWebContents == null) return;
            destroySpareWebContentsInternal();
        }
    }

    /**
     * Returns a spare WebContents or null, depending on the availability of one.
     *
     * The parameters are the same as for {@link WebContentsFactory#createWebContents()}.
     * @param forCCT Whether this WebContents is being taken by CCT.
     *
     * @return a WebContents, or null.
     */
    public WebContents takeSpareWebContents(boolean incognito, boolean initiallyHidden) {
        try (TraceEvent e = TraceEvent.scoped("WarmupManager.takeSpareWebContents")) {
            ThreadUtils.assertOnUiThread();
            if (incognito) return null;
            WebContents result = mSpareWebContents;
            if (result == null) return null;
            mSpareWebContents = null;
            result.removeObserver(mObserver);
            mObserver = null;
            if (!initiallyHidden) result.onShow();
            return result;
        }
    }

    /**
     * @return Whether a spare renderer is available.
     */
    public boolean hasSpareWebContents() {
        return mSpareWebContents != null;
    }

    private void destroySpareWebContentsInternal() {
        mSpareWebContents.removeObserver(mObserver);
        mSpareWebContents.destroy();
        mSpareWebContents = null;
        mObserver = null;
    }

    // We do some cleanup on Activity teardown, so to avoid activating the experiment for all users
    // regardless of whether they actually interact with the feature, cache the flag here.
    // This only works if no non-test code calls
    // ChromeFeatureList.isEnabled(ChromeFeatureList.CCT_PREWARM_TAB) directly.
    public boolean isCCTPrewarmTabFeatureEnabled(boolean activateExperiment) {
        if (activateExperiment) {
            mIsCCTPrewarmTabEnabled =
                    ChromeFeatureList.isEnabled(ChromeFeatureList.CCT_PREWARM_TAB);
        }
        return mIsCCTPrewarmTabEnabled;
    }

    @NativeMethods
    interface Natives {
        void startPreconnectPredictorInitialization(@JniType("Profile*") Profile profile);

        void preconnectUrlAndSubresources(@JniType("Profile*") Profile profile, String url);

        void startPrefetchFromCCT(
                WebContents webcontents,
                GURL url,
                boolean usePrefetchProxy,
                org.chromium.url.Origin verifiedSourceOrigin);
    }
}