chromium/chrome/android/java/src/org/chromium/chrome/browser/offlinepages/OfflinePageUtils.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.offlinepages;

import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.Environment;
import android.text.TextUtils;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.ContentUriUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.FileProviderHelper;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.SadTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorTabModelObserver;
import org.chromium.chrome.browser.ui.messages.snackbar.Snackbar;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController;
import org.chromium.chrome.browser.util.ChromeFileProvider;
import org.chromium.components.bookmarks.BookmarkId;
import org.chromium.components.browser_ui.share.ShareParams;
import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.offline_items_collection.LaunchLocation;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.WebContents;
import org.chromium.net.NetworkChangeNotifier;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.WindowAndroid;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** A class holding static util functions for offline pages. */
public class OfflinePageUtils {
    private static final String TAG = "OfflinePageUtils";

    private static final int DEFAULT_SNACKBAR_DURATION_MS = 6 * 1000; // 6 second

    // Used instead of the constant so tests can override the value.
    private static int sSnackbarDurationMs = DEFAULT_SNACKBAR_DURATION_MS;

    /** Instance carrying actual implementation of utility methods. */
    private static Internal sInstance;

    /**
     * Tracks the observers of each Activity's TabModelSelectors. This is weak so the activity can
     * be garbage collected without worrying about this map.  The RecentTabTracker is held here so
     * that it can be destroyed when the Activity gets a new TabModelSelector.
     */
    private static Map<Activity, RecentTabTracker> sTabModelObservers = new HashMap<>();

    /**
     * Interface for implementation of offline page utilities, that can be implemented for testing.
     * We are using an internal interface, so that instance methods can have the same names as
     * static methods.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public interface Internal {
        /** Returns offline page bridge for specified profile. */
        OfflinePageBridge getOfflinePageBridge(Profile profile);

        /** Returns whether the network is connected. */
        boolean isConnected();

        /**
         * Checks if an offline page is shown for the tab. This page could be either trusted or
         * untrusted.
         * @param tab The tab to be reloaded.
         * @return True if the offline page is opened.
         */
        boolean isOfflinePage(Tab tab);

        /**
         * Returns whether the WebContents is showing trusted offline page.
         * @param webContents The current WebContents.
         * @return True if a trusted offline page is shown in the webContents.
         */
        boolean isShowingTrustedOfflinePage(WebContents webContents);

        /**
         * Returns whether the tab is showing offline preview.
         * @param tab The current tab.
         */
        boolean isShowingOfflinePreview(Tab tab);

        /**
         * Shows the "reload" snackbar for the given tab.
         * @param context The application context.
         * @param snackbarManager Class that shows the snackbar.
         * @param snackbarController Class to control the snackbar.
         * @param tabId Id of a tab that the snackbar is related to.
         */
        void showReloadSnackbar(
                Context context,
                SnackbarManager snackbarManager,
                final SnackbarController snackbarController,
                int tabId);
    }

    private static class OfflinePageUtilsImpl implements Internal {
        @Override
        public OfflinePageBridge getOfflinePageBridge(Profile profile) {
            return OfflinePageBridge.getForProfile(profile);
        }

        @Override
        public boolean isConnected() {
            return NetworkChangeNotifier.isOnline();
        }

        @Override
        public boolean isOfflinePage(Tab tab) {
            if (tab == null) return false;

            WebContents webContents = tab.getWebContents();
            if (webContents == null) return false;

            OfflinePageBridge offlinePageBridge =
                    getInstance().getOfflinePageBridge(tab.getProfile());
            if (offlinePageBridge == null) return false;

            return offlinePageBridge.isOfflinePage(webContents);
        }

        @Override
        public boolean isShowingOfflinePreview(Tab tab) {
            OfflinePageBridge offlinePageBridge = getOfflinePageBridge(tab.getProfile());
            if (offlinePageBridge == null) return false;
            return offlinePageBridge.isShowingOfflinePreview(tab.getWebContents());
        }

        @Override
        public void showReloadSnackbar(
                Context context,
                SnackbarManager snackbarManager,
                final SnackbarController snackbarController,
                int tabId) {
            if (tabId == Tab.INVALID_TAB_ID) return;

            Log.d(TAG, "showReloadSnackbar called with controller " + snackbarController);
            Snackbar snackbar =
                    Snackbar.make(
                                    context.getString(R.string.offline_pages_viewing_offline_page),
                                    snackbarController,
                                    Snackbar.TYPE_ACTION,
                                    Snackbar.UMA_OFFLINE_PAGE_RELOAD)
                            .setSingleLine(false)
                            .setAction(context.getString(R.string.reload), tabId);
            snackbar.setDuration(sSnackbarDurationMs);
            snackbarManager.showSnackbar(snackbar);
        }

        @Override
        public boolean isShowingTrustedOfflinePage(WebContents webContents) {
            if (webContents == null) return false;

            OfflinePageBridge offlinePageBridge =
                    getOfflinePageBridge(Profile.fromWebContents(webContents));
            if (offlinePageBridge == null) return false;
            return offlinePageBridge.isShowingTrustedOfflinePage(webContents);
        }
    }

    private static Internal getInstance() {
        if (sInstance == null) {
            sInstance = new OfflinePageUtilsImpl();
        }
        return sInstance;
    }

    /** Returns the number of free bytes on the storage. */
    public static long getFreeSpaceInBytes() {
        return Environment.getDataDirectory().getUsableSpace();
    }

    /** Returns the number of total bytes on the storage. */
    public static long getTotalSpaceInBytes() {
        return Environment.getDataDirectory().getTotalSpace();
    }

    /** Returns whether the network is connected. */
    public static boolean isConnected() {
        return getInstance().isConnected();
    }

    /**
     * Save an offline copy for the bookmarked page asynchronously.
     *
     * @param bookmarkId The ID of the page to save an offline copy.
     * @param tab A {@link Tab} object.
     */
    public static void saveBookmarkOffline(BookmarkId bookmarkId, Tab tab) {
        // If bookmark ID is missing there is nothing to save here.
        if (bookmarkId == null) return;

        // Making sure tab is worth keeping.
        if (shouldSkipSavingTabOffline(tab)) return;

        OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(tab.getProfile());
        if (offlinePageBridge == null) return;

        WebContents webContents = tab.getWebContents();
        ClientId clientId = ClientId.createClientIdForBookmarkId(bookmarkId);

        offlinePageBridge.savePage(
                webContents,
                clientId,
                new OfflinePageBridge.SavePageCallback() {
                    @Override
                    public void onSavePageDone(int savePageResult, String url, long offlineId) {
                        // Result of the call is ignored.
                    }
                });
    }

    /**
     * Indicates whether we should skip saving the given tab as an offline page.
     * A tab shouldn't be saved offline if it shows an error page or a sad tab page.
     */
    private static boolean shouldSkipSavingTabOffline(Tab tab) {
        WebContents webContents = tab.getWebContents();
        return tab.isShowingErrorPage()
                || SadTab.isShowing(tab)
                || webContents == null
                || webContents.isDestroyed()
                || webContents.isIncognito();
    }

    /**
     * Shows the snackbar for the current tab to provide offline specific information if needed.
     * @param tab The current tab.
     */
    public static void showOfflineSnackbarIfNecessary(Tab tab) {
        // Set up the tab observer to watch for the tab being shown (not hidden) and a valid
        // connection. When both conditions are met a snackbar is shown.
        OfflinePageTabObserver.addObserverForTab(tab);
    }

    /**
     * Shows the "reload" snackbar for the given tab.
     * @param context The application context.
     * @param snackbarManager Class that shows the snackbar.
     * @param snackbarController Class to control the snackbar.
     * @param tabId Id of a tab that the snackbar is related to.
     */
    public static void showReloadSnackbar(
            Context context,
            SnackbarManager snackbarManager,
            final SnackbarController snackbarController,
            int tabId) {
        getInstance().showReloadSnackbar(context, snackbarManager, snackbarController, tabId);
    }

    private static void getOfflinePageUriForSharing(
            String tabUrl,
            boolean isPageTemporary,
            String offlinePagePath,
            Callback<Uri> callback) {
        // Ensure that we have a file path that is longer than just "/".
        if (isPageTemporary && offlinePagePath.length() > 1) {
            // We share temporary pages by content URI to prevent unanticipated side effects in the
            // public directory.

            // Avoid file access (getContentUriFromFile()) from UI thread.
            PostTask.postTask(
                    TaskTraits.USER_VISIBLE_MAY_BLOCK,
                    () -> {
                        File file = new File(offlinePagePath);
                        // We might get an exception if chrome does not have sharing roots
                        // configured.  If
                        // so, just share by URL of the original page instead of sharing the offline
                        // page.
                        Uri uri;
                        try {
                            uri = (new FileProviderHelper()).getContentUriFromFile(file);
                        } catch (Exception e) {
                            uri = Uri.parse(tabUrl);
                        }
                        final Uri finalUri = uri;
                        PostTask.postTask(TaskTraits.UI_DEFAULT, callback.bind(finalUri));
                    });
        } else {
            callback.onResult(Uri.parse(tabUrl));
        }
    }

    /**
     * If possible, creates the ShareParams needed to share the current offline page loaded in the
     * provided tab as a MHTML file.
     * @param tab The current tab from which the page is being shared.
     * @param shareCallback The callback invoked when either sharing is complete, or when sharing
     *                      cannot be completed. If sharing cannot be done, the callback parameter
     *                      is null. May either be invoked from within the function call, or
     *                      afterwards via PostTask.
     */
    public static void maybeShareOfflinePage(Tab tab, final Callback<ShareParams> shareCallback) {
        if (tab == null || !tab.isInitialized() || !OfflinePageUtils.isOfflinePage(tab)) {
            shareCallback.onResult(null);
            return;
        }

        OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(tab.getProfile());

        if (offlinePageBridge == null) {
            Log.e(TAG, "Unable to share current tab as an offline page.");
            shareCallback.onResult(null);
            return;
        }

        WebContents webContents = tab.getWebContents();
        if (webContents == null) {
            shareCallback.onResult(null);
            return;
        }

        OfflinePageItem offlinePage = offlinePageBridge.getOfflinePage(webContents);
        if (offlinePage == null) {
            shareCallback.onResult(null);
            return;
        }

        String offlinePath = offlinePage.getFilePath();

        boolean isPageTemporary =
                offlinePageBridge.isTemporaryNamespace(offlinePage.getClientId().getNamespace());
        String tabTitle = tab.getTitle();
        getOfflinePageUriForSharing(
                tab.getUrl().getSpec(),
                isPageTemporary,
                offlinePath,
                (Uri uri) ->
                        maybeShareOfflinePageWithUri(
                                tabTitle,
                                webContents,
                                offlinePageBridge,
                                offlinePage,
                                isPageTemporary,
                                shareCallback,
                                uri));
    }

    // A continuation of maybeShareOfflinePage, after the URI is determined in a background thread.
    private static void maybeShareOfflinePageWithUri(
            String tabTitle,
            WebContents webContents,
            OfflinePageBridge offlinePageBridge,
            OfflinePageItem offlinePage,
            boolean isPageTemporary,
            Callback<ShareParams> shareCallback,
            Uri uri) {
        if (!isOfflinePageShareable(offlinePageBridge, offlinePage, uri)) {
            shareCallback.onResult(null);
            return;
        }

        WindowAndroid window = webContents.getTopLevelNativeWindow();
        String offlinePath = offlinePage.getFilePath();
        if (isPageTemporary || !offlinePageBridge.isInPrivateDirectory(offlinePath)) {
            // Share temporary pages and pages already in a public location.
            final File offlinePageFile = new File(offlinePath);
            sharePage(
                    window, uri.toString(), tabTitle, offlinePath, offlinePageFile, shareCallback);
            return;
        }

        // The file access permission is needed since we may need to publish the archive
        // file if it resides in internal directory.
        offlinePageBridge.acquireFileAccessPermission(
                webContents,
                (granted) -> {
                    if (!granted) {
                        return;
                    }

                    // If the page is not in a public location, we must publish it before
                    // sharing it.
                    publishThenShareInternalPage(
                            window, offlinePageBridge, offlinePage, shareCallback);
                });
    }

    /**
     * Check to see if the offline page is sharable.
     * @param offlinePageBridge Bridge to native code for offline pages use.
     * @param offlinePage Page to check for sharability.
     * @param pageUri Uri of the page to check.
     * @return true if this page can be shared.
     */
    public static boolean isOfflinePageShareable(
            OfflinePageBridge offlinePageBridge, OfflinePageItem offlinePage, Uri uri) {
        // Return false if there is no offline page.
        if (offlinePage == null) return false;

        String offlinePath = offlinePage.getFilePath();

        // If we have a content or file Uri, then we can share the page.
        if (isSchemeContentOrFile(uri)) {
            return true;
        }

        // If the scheme is not one we recognize, return false.
        if (!TextUtils.equals(uri.getScheme(), UrlConstants.HTTP_SCHEME)
                && !TextUtils.equals(uri.getScheme(), UrlConstants.HTTPS_SCHEME)) {
            return false;
        }

        // If we have a http or https page with no file path, we cannot share it.
        if (offlinePath.isEmpty()) {
            Log.w(TAG, "Tried to share a page with no path.");
            return false;
        }

        return true;
    }

    // Returns true if the scheme of the URI is either content or file.
    private static boolean isSchemeContentOrFile(Uri uri) {
        boolean isContentScheme = TextUtils.equals(uri.getScheme(), UrlConstants.CONTENT_SCHEME);
        boolean isFileScheme = TextUtils.equals(uri.getScheme(), UrlConstants.FILE_SCHEME);

        return isContentScheme || isFileScheme;
    }

    /**
     * For internal pages, we must publish them, then share them.
     * @param window The window that triggered the share action.
     * @param offlinePageBridge Bridge to native code for offline pages use.
     * @param offlinePage Page to publish and share.
     * @param shareCallback The callback to be used to send the ShareParams.
     */
    public static void publishThenShareInternalPage(
            final WindowAndroid window,
            OfflinePageBridge offlinePageBridge,
            OfflinePageItem offlinePage,
            final Callback<ShareParams> shareCallback) {
        PublishPageCallback publishPageCallback =
                new PublishPageCallback(window, offlinePage, shareCallback);
        offlinePageBridge.publishInternalPageByOfflineId(
                offlinePage.getOfflineId(), publishPageCallback);
    }

    /** Called when publishing is done. Continues with processing to share. */
    public static void publishCompleted(
            OfflinePageItem page,
            final WindowAndroid window,
            final Callback<ShareParams> shareCallback) {
        sharePublishedPage(page, window, shareCallback);
    }

    /** This will take a page in a public directory, and share it. */
    public static void sharePublishedPage(
            OfflinePageItem page,
            final WindowAndroid window,
            final Callback<ShareParams> shareCallback) {
        if (page == null) {
            // For errors, we don't call the shareCallback.  The callback only causes the page to be
            // shared, and does not report errors, and is not needed to continue processing.
            return;
        }
        final String pageUrl = page.getUrl();
        final String pageTitle = page.getTitle();
        final File offlinePageFile = new File(page.getFilePath());
        sharePage(window, pageUrl, pageTitle, page.getFilePath(), offlinePageFile, shareCallback);
    }

    /** Share the page. */
    public static void sharePage(
            WindowAndroid window,
            String pageUrl,
            String pageTitle,
            String offlinePath,
            File offlinePageFile,
            final Callback<ShareParams> shareCallback) {
        RecordUserAction.record("OfflinePages.Sharing.SharePageFromOverflowMenu");
        AsyncTask<Uri> task =
                new AsyncTask<Uri>() {
                    @Override
                    protected Uri doInBackground() {
                        // Android Q+: If we already have a content URI for the published page,
                        // return that.
                        if (ContentUriUtils.isContentUri(offlinePath)) {
                            return Uri.parse(offlinePath);
                        }

                        // If we have a content or file URI, we will not have a filename, just
                        // return the URI.
                        if (offlinePath.isEmpty()) {
                            Uri uri = Uri.parse(pageUrl);
                            assert (isSchemeContentOrFile(uri));
                            return uri;
                        }

                        // TODO(crbug.com/40636789): Investigate why we sometimes aren't able to
                        // generate URIs
                        // for files in external storage.
                        Uri generatedUri;
                        try {
                            generatedUri = ChromeFileProvider.generateUri(offlinePageFile);
                        } catch (IllegalArgumentException e) {
                            Log.e(TAG, "Couldn't generate URI for sharing page: " + e);
                            generatedUri = Uri.parse(pageUrl);
                        }
                        return generatedUri;
                    }

                    @Override
                    protected void onPostExecute(Uri uri) {
                        ShareParams.Builder builder =
                                new ShareParams.Builder(window, pageTitle, pageUrl);
                        // Only try to share the offline page if we have a content URI making the
                        // actual file available.
                        // TODO(crbug.com/40636789): Sharing the page's online URL is a temporary
                        // fix for
                        // crashes when sharing the archive's content URI. Once the root cause is
                        // addressed, the offline URI should always be set.
                        if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
                            builder = builder.setOfflineUri(uri);
                        }

                        shareCallback.onResult(builder.build());
                    }
                };
        task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    }

    /**
     * Retrieves the extra request header to reload the offline page.
     * @param webContents The current WebContents.
     * @return The extra request header string.
     */
    public static String getOfflinePageHeaderForReload(WebContents webContents) {
        OfflinePageBridge offlinePageBridge =
                getInstance().getOfflinePageBridge(Profile.fromWebContents(webContents));
        if (offlinePageBridge == null) return "";
        return offlinePageBridge.getOfflinePageHeaderForReload(webContents);
    }

    /**
     * A load url parameters to open offline version of the offline page. If the offline page is
     * trusted, the URL (http/https) of the offline page is to be opened. Otherwise, the file URL
     * pointing to the archive file is to be opened. In both cases, a custom header is passed with
     * the URL to ensure loading a specific version of offline page.
     * @param url       The url of the offline page to open.
     * @param offlineId The ID of the offline page to open.
     * @param location  Indicates where the offline page is launched.
     * @param callback  The callback to pass back the LoadUrlParams for launching an URL.
     * @param profile   The profile to get an instance of OfflinePageBridge.
     */
    public static void getLoadUrlParamsForOpeningOfflineVersion(
            final String url,
            long offlineId,
            final @LaunchLocation int location,
            Callback<LoadUrlParams> callback,
            Profile profile) {
        OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(profile);
        if (offlinePageBridge == null) {
            callback.onResult(null);
            return;
        }

        offlinePageBridge.getLoadUrlParamsByOfflineId(
                offlineId,
                location,
                (loadUrlParams) -> {
                    callback.onResult(loadUrlParams);
                });
    }

    /**
     * A load url parameters to handle the intent for viewing MHTML file or content. If the
     * trusted offline page is found, the URL (http/https) of the offline page is to be opened.
     * Otherwise, the file or content URL from the intent will be launched.
     * @param intentUrl URL from the intent.
     * @param callback  The callback to pass back the launching URL and extra headers.
     * @param profile   The profile to get an instance of OfflinePageBridge.
     */
    public static void getLoadUrlParamsForOpeningMhtmlFileOrContent(
            final String intentUrl, Callback<LoadUrlParams> callback, Profile profile) {
        OfflinePageBridge offlinePageBridge = getInstance().getOfflinePageBridge(profile);
        if (offlinePageBridge == null) {
            callback.onResult(new LoadUrlParams(intentUrl));
            return;
        }

        offlinePageBridge.getLoadUrlParamsForOpeningMhtmlFileOrContent(intentUrl, callback);
    }

    /**
     * @return True if an offline preview is being shown.
     * @param tab The current tab.
     */
    public static boolean isShowingOfflinePreview(Tab tab) {
        return getInstance().isShowingOfflinePreview(tab);
    }

    /**
     * Checks if an offline page is shown for the tab.
     * @param tab The tab to be reloaded.
     * @return True if the offline page is opened.
     */
    public static boolean isOfflinePage(Tab tab) {
        return getInstance().isOfflinePage(tab);
    }

    /**
     * Retrieves the offline page that is shown for the web-content.
     * @param webContents The WebContents to be reloaded.
     * @return The offline page if tab currently displays it, null otherwise.
     */
    public static OfflinePageItem getOfflinePage(WebContents webContents) {
        if (webContents == null) return null;
        OfflinePageBridge offlinePageBridge =
                getInstance().getOfflinePageBridge(Profile.fromWebContents(webContents));
        if (offlinePageBridge == null) return null;
        return offlinePageBridge.getOfflinePage(webContents);
    }

    /**
     * Returns whether the WebContents is showing trusted offline page.
     * @param webContents The current WebContents.
     * @return True if a trusted offline page is shown in the webContents.
     */
    public static boolean isShowingTrustedOfflinePage(WebContents webContents) {
        return getInstance().isShowingTrustedOfflinePage(webContents);
    }

    /**
     * This interface delegates some WebContents-related and Tab-related methods to the callers.
     * Since Tab wraps WebContents, the Tab-related methods sometimes have extra steps to take on
     * top of WebContents' counterpart methods. This interface is designed to ensure these extra
     * steps have been taken into account.
     */
    public static interface OfflinePageLoadUrlDelegate {
        /**
         * Load the url of the given {@link LoadUrlParam} in WebContents.
         * @param params The LoadUrlParams that has specified which url to load.
         */
        /* package */ void loadUrl(LoadUrlParams params);
    }

    /** This class defines the OfflinePageLoadUrlDelegate for a WebContents. */
    public static class WebContentsOfflinePageLoadUrlDelegate
            implements OfflinePageLoadUrlDelegate {
        private final WebContents mWebContents;

        /** Construct the class with a WebContents. */
        public WebContentsOfflinePageLoadUrlDelegate(WebContents webContents) {
            mWebContents = webContents;
        }

        @Override
        public void loadUrl(LoadUrlParams params) {
            mWebContents.getNavigationController().loadUrl(params);
        }
    }

    /**
     * This class defines the OfflinePageLoadUrlDelegate for a Tab. Since Tab wraps WebContents,
     * these delegate methods ensures that the Tab's extra considerations on top of WebContents,
     * like distilled url, TabObserver, etc., have been taken into account.
     */
    public static class TabOfflinePageLoadUrlDelegate implements OfflinePageLoadUrlDelegate {
        private final Tab mTab;

        /** Construct the class with a tab. */
        public TabOfflinePageLoadUrlDelegate(Tab tab) {
            mTab = tab;
        }

        @Override
        public void loadUrl(LoadUrlParams params) {
            mTab.loadUrl(params);
        }
    }

    /**
     * Reloads specified webContents, which should allow to open an online version of the page.
     * @param loadUrlDelegate The delegate to load a page (e.g., WebContents, Tab).
     */
    public static void reload(WebContents webContents, OfflinePageLoadUrlDelegate loadUrlDelegate) {
        // Only the transition type with both RELOAD and FROM_ADDRESS_BAR set will force the
        // navigation to be treated as reload (see ShouldTreatNavigationAsReload()). Without this,
        // reloading an URL containing a hash will be treated as same document load and thus
        // no loading is triggered.
        int transitionTypeForReload = PageTransition.RELOAD | PageTransition.FROM_ADDRESS_BAR;

        OfflinePageItem offlinePage = getOfflinePage(webContents);
        if (OfflinePageUtils.isShowingTrustedOfflinePage(webContents) || offlinePage == null) {
            // TODO(crbug.com/40663204): dedupe the
            // DomDistillerUrlUtils#getOriginalUrlFromDistillerUrl() calls.
            String distilledUrl =
                    DomDistillerUrlUtils.getOriginalUrlFromDistillerUrl(webContents.getVisibleUrl())
                            .getSpec();
            // If current page is an offline page, reload it with custom behavior defined in extra
            // header respected.
            LoadUrlParams params = new LoadUrlParams(distilledUrl, transitionTypeForReload);
            params.setVerbatimHeaders(getOfflinePageHeaderForReload(webContents));
            loadUrlDelegate.loadUrl(params);
            return;
        }

        LoadUrlParams params = new LoadUrlParams(offlinePage.getUrl(), transitionTypeForReload);
        loadUrlDelegate.loadUrl(params);
    }

    /**
     * Tracks tab creation and closure for the Recent Tabs feature.  UI needs to stop showing
     * recent offline pages as soon as the tab is closed.  The TabModel is used to get profile
     * information because Tab's profile is tied to the native WebContents, which may not exist at
     * tab adding or tab closing time.
     */
    private static class RecentTabTracker extends TabModelSelectorTabModelObserver {
        /** The single, stateless TabRestoreTracker instance to monitor all tab restores. */

        private TabModelSelector mTabModelSelector;

        public RecentTabTracker(TabModelSelector selector) {
            super(selector);
            mTabModelSelector = selector;
        }

        @Override
        public void willCloseTab(Tab tab, boolean didCloseAlone) {
            Profile profile = mTabModelSelector.getModel(tab.isIncognito()).getProfile();
            OfflinePageBridge bridge = OfflinePageBridge.getForProfile(profile);
            if (bridge == null) return;

            WebContents webContents = tab.getWebContents();
            if (webContents != null) bridge.willCloseTab(webContents);
        }

        @Override
        public void onFinishingTabClosure(Tab tab) {
            Profile profile = mTabModelSelector.getModel(tab.isIncognito()).getProfile();
            OfflinePageBridge bridge = OfflinePageBridge.getForProfile(profile);
            if (bridge == null) return;

            // Delete any "Last N" offline pages as well. This is an optimization because
            // the UI will no longer show the page, and the page would also be cleaned up by GC
            // given enough time.
            ClientId clientId =
                    new ClientId(OfflinePageBridge.LAST_N_NAMESPACE, Integer.toString(tab.getId()));
            List<ClientId> clientIds = new ArrayList<>();
            clientIds.add(clientId);

            bridge.deletePagesByClientId(
                    clientIds,
                    new Callback<Integer>() {
                        @Override
                        public void onResult(Integer result) {
                            // Result is ignored.
                        }
                    });
        }
    }

    /**
     * Starts tracking the tab models in the given selector for tab addition and closure,
     * destroying obsolete observers as necessary.
     */
    public static void observeTabModelSelector(
            Activity activity, TabModelSelector tabModelSelector) {
        RecentTabTracker previousObserver =
                sTabModelObservers.put(activity, new RecentTabTracker(tabModelSelector));
        if (previousObserver != null) {
            previousObserver.destroy();
        } else {
            // This is the 1st time we see this activity so register a state listener with it.
            ApplicationStatus.registerStateListenerForActivity(
                    new ApplicationStatus.ActivityStateListener() {
                        @Override
                        public void onActivityStateChange(Activity activity, int newState) {
                            if (newState == ActivityState.DESTROYED) {
                                sTabModelObservers.remove(activity).destroy();
                                ApplicationStatus.unregisterActivityStateListener(this);
                            }
                        }
                    },
                    activity);
        }
    }

    public static void setInstanceForTesting(Internal instance) {
        var oldValue = sInstance;
        sInstance = instance;
        ResettersForTesting.register(() -> sInstance = oldValue);
    }

    public static void setSnackbarDurationForTesting(int durationMs) {
        var oldValue = sSnackbarDurationMs;
        sSnackbarDurationMs = durationMs;
        ResettersForTesting.register(() -> sSnackbarDurationMs = oldValue);
    }
}