chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/CustomTabActivityNavigationController.java

// Copyright 2019 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.content;

import static org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController.FinishReason.OTHER;
import static org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController.FinishReason.REPARENTING;
import static org.chromium.chrome.browser.customtabs.content.CustomTabActivityNavigationController.FinishReason.USER_NAVIGATION;

import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Browser;
import android.text.TextUtils;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityOptionsCompat;

import dagger.Lazy;

import org.chromium.base.ContextUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.back_press.BackPressManager;
import org.chromium.chrome.browser.back_press.MinimizeAppAndCloseTabBackPressHandler;
import org.chromium.chrome.browser.back_press.MinimizeAppAndCloseTabBackPressHandler.MinimizeAppAndCloseTabType;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.CloseButtonNavigator;
import org.chromium.chrome.browser.customtabs.CustomTabObserver;
import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.externalnav.ExternalNavigationDelegateImpl;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.init.ChromeBrowserInitializer;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.StartStopWithNativeObserver;
import org.chromium.chrome.browser.profiles.ProfileProvider;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.toolbar.ToolbarManager;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler;
import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.PageTransition;
import org.chromium.url.GURL;
import org.chromium.url.Origin;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.function.Predicate;

import javax.inject.Inject;

/** Responsible for navigating to new pages and going back to previous pages. */
@ActivityScope
public class CustomTabActivityNavigationController
        implements StartStopWithNativeObserver, BackPressHandler {
    @IntDef({
        FinishReason.USER_NAVIGATION,
        FinishReason.REPARENTING,
        FinishReason.OTHER,
        FinishReason.OPEN_IN_BROWSER
    })
    @Target(ElementType.TYPE_USE)
    @Retention(RetentionPolicy.SOURCE)
    public @interface FinishReason {
        int USER_NAVIGATION = 0;
        // The web page is opened in the same browser by reparenting the tab into the browser.
        int REPARENTING = 1;
        int OTHER = 2;
        // The web page is opened in the default browser by starting a new activity.
        int OPEN_IN_BROWSER = 3;
    }

    /** A handler of back presses. */
    public interface BackHandler {
        /**
         * Called when back button is pressed, unless already handled by another handler.
         * The implementation should do one of the following:
         * 1) Synchronously accept and handle the event and return true;
         * 2) Synchronously reject the event by returning false;
         * 3) Accept the event by returning true, handle it asynchronously, and if the handling
         * fails, trigger the default handling routine by running the defaultBackHandler.
         */
        boolean handleBackPressed(Runnable defaultBackHandler);
    }

    /** Interface encapsulating the process of handling the custom tab closing. */
    public interface FinishHandler {
        void onFinish(@FinishReason int reason, boolean warmupOnFinish);
    }

    /** Interface which gets the package name of the default web browser on the device. */
    public interface DefaultBrowserProvider {
        /** Returns the package name for the default browser on the device as a string. */
        @Nullable
        String getDefaultBrowser();
    }

    private final OneshotSupplier<ProfileProvider> mProfileProviderSupplier;
    private final CustomTabActivityTabController mTabController;
    private final CustomTabActivityTabProvider mTabProvider;
    private final BrowserServicesIntentDataProvider mIntentDataProvider;
    private final CustomTabsConnection mConnection;
    private final Lazy<CustomTabObserver> mCustomTabObserver;
    private final CloseButtonNavigator mCloseButtonNavigator;
    private final ChromeBrowserInitializer mChromeBrowserInitializer;
    private final Activity mActivity;
    private final DefaultBrowserProvider mDefaultBrowserProvider;
    private final ObservableSupplierImpl<Boolean> mBackPressStateSupplier =
            new ObservableSupplierImpl<>(false);

    @Nullable private ToolbarManager mToolbarManager;

    @Nullable private FinishHandler mFinishHandler;

    private boolean mIsFinishing;

    private boolean mIsHandlingUserNavigation;

    private @FinishReason int mFinishReason;

    private final CustomTabActivityTabProvider.Observer mTabObserver =
            new CustomTabActivityTabProvider.Observer() {
                @Override
                public void onInitialTabCreated(@NonNull Tab tab, int mode) {
                    mBackPressStateSupplier.set(shouldInterceptBackPress());
                }

                @Override
                public void onTabSwapped(@NonNull Tab tab) {
                    mBackPressStateSupplier.set(shouldInterceptBackPress());
                }

                @Override
                public void onAllTabsClosed() {
                    mBackPressStateSupplier.set(shouldInterceptBackPress());
                    finish(mIsHandlingUserNavigation ? USER_NAVIGATION : OTHER);
                }

                private boolean shouldInterceptBackPress() {
                    return mTabProvider.getTab() != null
                            && mChromeBrowserInitializer.isFullBrowserInitialized();
                }
            };

    @Inject
    public CustomTabActivityNavigationController(
            OneshotSupplier<ProfileProvider> profileProviderSupplier,
            CustomTabActivityTabController tabController,
            CustomTabActivityTabProvider tabProvider,
            BrowserServicesIntentDataProvider intentDataProvider,
            CustomTabsConnection connection,
            Lazy<CustomTabObserver> customTabObserver,
            CloseButtonNavigator closeButtonNavigator,
            ChromeBrowserInitializer chromeBrowserInitializer,
            Activity activity,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            DefaultBrowserProvider customTabsDefaultBrowserProvider) {
        mProfileProviderSupplier = profileProviderSupplier;
        mTabController = tabController;
        mTabProvider = tabProvider;
        mIntentDataProvider = intentDataProvider;
        mConnection = connection;
        mCustomTabObserver = customTabObserver;
        mCloseButtonNavigator = closeButtonNavigator;
        mChromeBrowserInitializer = chromeBrowserInitializer;
        mActivity = activity;
        mDefaultBrowserProvider = customTabsDefaultBrowserProvider;

        lifecycleDispatcher.register(this);
        mTabProvider.addObserver(mTabObserver);
        mChromeBrowserInitializer.runNowOrAfterFullBrowserStarted(
                () -> {
                    mBackPressStateSupplier.set(mTabProvider.getTab() != null);
                });
    }

    /**
     * Notifies the navigation controller that the ToolbarManager has been created and is ready for
     * use. ToolbarManager isn't passed directly to the constructor because it's not guaranteed to
     * be initialized yet.
     */
    public void onToolbarInitialized(ToolbarManager manager) {
        assert manager != null : "Toolbar manager not initialized";
        mToolbarManager = manager;
    }

    /**
     * Performs navigation using given {@link LoadUrlParams}.
     * The source Intent is used for tracking page loading times (see {@link CustomTabObserver}).
     */
    public void navigate(final LoadUrlParams params, Intent sourceIntent) {
        Tab tab = mTabProvider.getTab();
        if (tab == null) {
            assert false;
            return;
        }

        if (tab.isDestroyed()) {
            // This code path may be called asynchronously, assume that if the tab has been
            // destroyed there is no point in continuing.
            return;
        }

        // TODO(pkotwicz): Figure out whether we want to record these metrics for WebAPKs.
        if (mIntentDataProvider.getWebappExtras() == null) {
            mCustomTabObserver.get().trackNextPageLoadForLaunch(tab, sourceIntent);
        }

        IntentHandler.addReferrerAndHeaders(params, mIntentDataProvider.getIntent());

        // Launching a TWA, WebAPK or a standalone-mode homescreen shortcut counts as a TOPLEVEL
        // transition since it opens up an app-like experience, and should count towards site
        // engagement scores. CCTs on the other hand still count as LINK transitions.
        int transition;
        if (mIntentDataProvider.isTrustedWebActivity()
                || mIntentDataProvider.isWebappOrWebApkActivity()) {
            transition = PageTransition.AUTO_TOPLEVEL | PageTransition.FROM_API;
        } else {
            transition = PageTransition.LINK | PageTransition.FROM_API;
        }

        params.setTransitionType(
                IntentHandler.getTransitionTypeFromIntent(
                        mIntentDataProvider.getIntent(), transition));

        // The sender of an intent can't be trusted, so we navigate from an opaque Origin to
        // avoid sending same-site cookies.
        params.setInitiatorOrigin(Origin.createOpaqueOrigin());

        tab.loadUrl(params);
    }

    /** Handles back button navigation. */
    public boolean navigateOnBack() {
        if (!mChromeBrowserInitializer.isFullBrowserInitialized()) return false;

        boolean separateTask =
                (mIntentDataProvider.getIntent().getFlags()
                                & (Intent.FLAG_ACTIVITY_NEW_TASK
                                        | Intent.FLAG_ACTIVITY_NEW_DOCUMENT))
                        != 0;
        RecordUserAction.record("CustomTabs.SystemBack");
        if (mTabProvider.getTab() == null) return false;
        if (!BackPressManager.isEnabled()) {
            // If enabled, BackPressManager, rather than this class, will trigger their custom
            // logic of handling back press.
            final WebContents webContents = mTabProvider.getTab().getWebContents();
            if (webContents != null) {
                RenderFrameHost focusedFrame = webContents.getFocusedFrame();
                if (focusedFrame != null && focusedFrame.signalCloseWatcherIfActive()) {
                    BackPressManager.record(BackPressHandler.Type.CLOSE_WATCHER);
                    BackPressManager.recordForCustomTab(
                            BackPressHandler.Type.CLOSE_WATCHER, separateTask);
                    return true;
                }
            }

            if (mToolbarManager != null && mToolbarManager.back()) {
                BackPressManager.record(BackPressHandler.Type.TAB_HISTORY);
                BackPressManager.recordForCustomTab(
                        BackPressHandler.Type.TAB_HISTORY, separateTask);
                return true;
            }
            // If enabled, BackPressManager will record this internally. Otherwise, this should
            // be recorded manually.
            BackPressManager.record(BackPressHandler.Type.MINIMIZE_APP_AND_CLOSE_TAB);
            BackPressManager.recordForCustomTab(
                    BackPressHandler.Type.MINIMIZE_APP_AND_CLOSE_TAB, separateTask);
        } else if (BackPressManager.correctTabNavigationOnFallback()) {
            if (mTabProvider.getTab().canGoBack()) {
                return false;
            }
        }

        if (ChromeFeatureList.isEnabled(ChromeFeatureList.CCT_BEFORE_UNLOAD)
                && mTabController.onlyOneTabRemaining()) {
            finishActivity(separateTask);
            return true;
        }

        if (mTabController.dispatchBeforeUnloadIfNeeded()) {
            MinimizeAppAndCloseTabBackPressHandler.record(MinimizeAppAndCloseTabType.CLOSE_TAB);
            MinimizeAppAndCloseTabBackPressHandler.recordForCustomTab(
                    MinimizeAppAndCloseTabType.CLOSE_TAB, separateTask);
            return true;
        }
        if (mTabController.onlyOneTabRemaining()) {
            finishActivity(separateTask);
        } else {
            MinimizeAppAndCloseTabBackPressHandler.record(MinimizeAppAndCloseTabType.CLOSE_TAB);
            MinimizeAppAndCloseTabBackPressHandler.recordForCustomTab(
                    MinimizeAppAndCloseTabType.CLOSE_TAB, separateTask);
            mTabController.closeTab();
        }

        return true;
    }

    private void finishActivity(boolean separateTask) {
        // If we're closing the last tab and it doesn't have beforeunload, just finish the Activity
        // manually. If we had called mTabController.closeTab() and waited for the Activity to close
        // as a result we would have a visual glitch: https://crbug.com/1087108.
        MinimizeAppAndCloseTabBackPressHandler.record(MinimizeAppAndCloseTabType.MINIMIZE_APP);
        MinimizeAppAndCloseTabBackPressHandler.recordForCustomTab(
                MinimizeAppAndCloseTabType.MINIMIZE_APP, separateTask);
        finish(USER_NAVIGATION);
    }

    @Override
    public int handleBackPress() {
        return navigateOnBack() ? BackPressResult.SUCCESS : BackPressResult.FAILURE;
    }

    @Override
    public ObservableSupplier<Boolean> getHandleBackPressChangedSupplier() {
        return mBackPressStateSupplier;
    }

    /** Handles close button navigation. */
    public void navigateOnClose() {
        mIsHandlingUserNavigation = true;
        mCloseButtonNavigator.navigateOnClose(this::finish);
        mIsHandlingUserNavigation = false;
    }

    /**
     * Opens the URL currently being displayed in the Custom Tab in the regular browser.
     *
     * @return Whether or not the tab was sent over successfully.
     */
    public boolean openCurrentUrlInBrowser() {
        Tab tab = mTabProvider.getTab();
        if (tab == null) return false;

        GURL gurl = tab.getUrl();
        if (DomDistillerUrlUtils.isDistilledPage(gurl)) {
            gurl = DomDistillerUrlUtils.getOriginalUrlFromDistillerUrl(gurl);
        }
        String url = gurl.getSpec();
        if (TextUtils.isEmpty(url)) url = mIntentDataProvider.getUrlToLoad();
        Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.putExtra(IntentHandler.EXTRA_FROM_OPEN_IN_BROWSER, true);
        String packageName = mDefaultBrowserProvider.getDefaultBrowser();
        if (packageName != null) {
            intent.setPackage(packageName);
            // crbug.com/1265223
            if (intent.resolveActivity(mActivity.getPackageManager()) == null) {
                intent.setPackage(null);
            }
        }

        boolean isOffTheRecord = mIntentDataProvider.isOffTheRecord();
        boolean willChromeHandleIntent = mIntentDataProvider.isOpenedByChrome();

        // If the tab is opened by TWA or Webapp, do not reparent and finish the Custom Tab
        // activity because we still want to keep the app alive.
        boolean canFinishActivity =
                !mIntentDataProvider.isTrustedWebActivity()
                        && !mIntentDataProvider.isWebappOrWebApkActivity();

        willChromeHandleIntent |=
                ExternalNavigationDelegateImpl.willChromeHandleIntent(intent, true);

        Bundle startActivityOptions =
                ActivityOptionsCompat.makeCustomAnimation(
                                mActivity, R.anim.abc_fade_in, R.anim.abc_fade_out)
                        .toBundle();

        if (isOffTheRecord) {
            // If "Open in browser" was triggered in an OTR CCT, always open in a new Chrome
            // Incognito tab instead of re-parenting the tab to prevent profile-mismatch with the
            // TabModel as both eCCT & iCCT have a different OTRProfileID from the primary OTR
            // profile.
            intent.setClass(ContextUtils.getApplicationContext(), ChromeLauncherActivity.class);
            intent.setPackage(ContextUtils.getApplicationContext().getPackageName());
            intent.putExtra(
                    Browser.EXTRA_APPLICATION_ID,
                    ContextUtils.getApplicationContext().getPackageName());
            intent.putExtra(IntentHandler.EXTRA_OPEN_NEW_INCOGNITO_TAB, true);
            IntentUtils.addTrustedIntentExtras(intent);

            mActivity.startActivity(intent, startActivityOptions);
            finish(FinishReason.OPEN_IN_BROWSER);
        } else if (canFinishActivity && willChromeHandleIntent) {
            // Remove observer to not trigger finishing in onAllTabsClosed() callback - we'll use
            // reparenting finish callback instead.
            mTabProvider.removeObserver(mTabObserver);
            mTabController.detachAndStartReparenting(
                    intent, startActivityOptions, () -> finish(FinishReason.REPARENTING));
        } else {
            if (mIntentDataProvider.isInfoPage()) {
                IntentHandler.startChromeLauncherActivityForTrustedIntent(intent);
            } else {
                mActivity.startActivity(intent, startActivityOptions);
                finish(FinishReason.OPEN_IN_BROWSER);
            }
        }
        return true;
    }

    /**
     * Finishes the Custom Tab activity and removes the reference from the Android recents.
     *
     * @param reason The reason for finishing.
     */
    public void finish(@FinishReason int reason) {
        if (mIsFinishing) return;
        mIsFinishing = true;
        mFinishReason = reason;
        // Closing the activity destroys the renderer as well. Re-create a spare renderer some
        // time after, so that we have one ready for the next tab open. This does not increase
        // memory consumption, as the current renderer goes away. We create a renderer as a lot
        // of users open several Custom Tabs in a row. The delay is there to avoid jank in the
        // transition animation when closing the tab.
        boolean warmupOnFinish = reason != REPARENTING;

        if (mFinishHandler != null) {
            mFinishHandler.onFinish(reason, warmupOnFinish);
        }
    }

    public @FinishReason int getFinishReason() {
        return mFinishReason;
    }

    /** Sets a {@link FinishHandler} to be notified when the custom tab is being closed. */
    public void setFinishHandler(FinishHandler finishHandler) {
        assert mFinishHandler == null
                : "Multiple FinishedHandlers not supported, replace with ObserverList if necessary";
        mFinishHandler = finishHandler;
    }

    /**
     * Sets a criterion to choose a page to land to when close button is pressed.
     * Only one such criterion can be set.
     * If no page in the navigation history meets the criterion, or there is no criterion, then
     * pressing close button will finish the Custom Tab activity.
     */
    public void setLandingPageOnCloseCriterion(Predicate<String> criterion) {
        mCloseButtonNavigator.setLandingPageCriteria(criterion);
    }

    @Override
    public void onStartWithNative() {
        mIsFinishing = false;
    }

    @Override
    public void onStopWithNative() {
        if (mIsFinishing) {
            mTabController.closeAndForgetTab();
        } else {
            mTabController.saveState();
        }
    }
}