chromium/chrome/android/java/src/org/chromium/chrome/browser/compositor/bottombar/OverlayPanelContent.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.compositor.bottombar;

import android.app.Activity;
import android.text.TextUtils;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;

import org.chromium.base.supplier.Supplier;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.chrome.browser.content.ContentUtils;
import org.chromium.chrome.browser.content.WebContentsFactory;
import org.chromium.chrome.browser.contextualsearch.ContextualSearchManager;
import org.chromium.chrome.browser.externalnav.ExternalNavigationDelegateImpl;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.embedder_support.delegate.WebContentsDelegateAndroid;
import org.chromium.components.embedder_support.view.ContentView;
import org.chromium.components.external_intents.ExternalNavigationHandler;
import org.chromium.components.navigation_interception.InterceptNavigationDelegate;
import org.chromium.content_public.browser.LoadCommittedDetails;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.RenderCoordinates;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.base.PageTransition;
import org.chromium.ui.base.ViewAndroidDelegate;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;
import org.chromium.url.Origin;

/**
 * Content container for an OverlayPanel. This class is responsible for the management of the
 * WebContents displayed inside of a panel and exposes a simple API relevant to actions a
 * panel has.
 */
public class OverlayPanelContent {
    /** The {@link CompositorViewHolder} for the current activity, used to add/remove views. */
    private final ViewGroup mCompositorViewHolder;

    /** The {@link WindowAndroid} for the current activity. */
    private final WindowAndroid mWindowAndroid;

    /** Supplies the current activity {@link Tab}. */
    private final Supplier<Tab> mCurrentTabSupplier;

    /** Used for progress bar events. */
    private final WebContentsDelegateAndroid mWebContentsDelegate;

    /** The Profile this OverlayPanel is associated with. */
    private final Profile mProfile;

    /** The WebContents that this panel will display. */
    private WebContents mWebContents;

    /** The container view that this panel uses. */
    private ViewGroup mContainerView;

    /** The pointer to the native version of this class. */
    private long mNativeOverlayPanelContentPtr;

    /** The activity that this content is contained in. */
    private Activity mActivity;

    /** Observer used for tracking loading and navigation. */
    private WebContentsObserver mWebContentsObserver;

    /** The URL that was directly loaded using the {@link #loadUrl(String)} method. */
    private String mLoadedUrl;

    /** Whether the content has started loading a URL. */
    private boolean mDidStartLoadingUrl;

    /**
     * Whether we should reuse any existing WebContents instead of deleting and recreating.
     * See crbug.com/682953 for details.
     */
    private boolean mShouldReuseWebContents;

    /**
     * Whether the WebContents is processing a pending navigation.
     * NOTE(pedrosimonetti): This is being used to prevent redirections on the SERP to be
     * interpreted as a regular navigation, which should cause the Contextual Search Panel
     * to be promoted as a Tab. This was added to work around a server bug that has been fixed.
     * Just checking for whether the Content has been touched is enough to determine whether a
     * navigation should be promoted (assuming it was caused by the touch), as done in
     * {@link ContextualSearchManager#shouldPromoteSearchNavigation()}.
     * For more details, see crbug.com/441048
     * TODO(pedrosimonetti): remove this from M48 or move it to Contextual Search Panel.
     */
    private boolean mIsProcessingPendingNavigation;

    /** Whether the content view is currently being displayed. */
    private boolean mIsContentViewShowing;

    /** The observer used by this object to inform implementers of different events. */
    private OverlayPanelContentDelegate mContentDelegate;

    /** Used to observe progress bar events. */
    private OverlayPanelContentProgressObserver mProgressObserver;

    /** If a URL is set to delayed load (load on user interaction), it will be stored here. */
    private String mPendingUrl;

    // http://crbug.com/522266 : An instance of InterceptNavigationDelegateImpl should be kept in
    // java layer. Otherwise, the instance could be garbage-collected unexpectedly.
    private InterceptNavigationDelegate mInterceptNavigationDelegate;

    /** The desired size of the {@link ContentView} associated with this panel content. */
    private int mContentViewWidth;

    private int mContentViewHeight;
    private boolean mSubtractBarHeight;

    /** The height of the bar at the top of the OverlayPanel in pixels. */
    private final int mBarHeightPx;

    /** Sets the top offset of the overlay panel in pixel. 0 when fully expanded. */
    private int mPanelTopOffsetPx;

    private class OverlayViewDelegate extends ViewAndroidDelegate {
        public OverlayViewDelegate(ViewGroup v) {
            super(v);
        }

        @Override
        public void setViewPosition(
                View view,
                float x,
                float y,
                float width,
                float height,
                int leftMargin,
                int topMargin) {
            super.setViewPosition(view, x, y, width, height, leftMargin, topMargin);

            // Applies top offset depending on the overlay panel state.
            MarginLayoutParams lp = (MarginLayoutParams) view.getLayoutParams();
            lp.topMargin += mPanelTopOffsetPx + mBarHeightPx;
        }
    }

    // ============================================================================================
    // InterceptNavigationDelegateImpl
    // ============================================================================================

    // Used to intercept intent navigations.
    // TODO(jeremycho): Consider creating a Tab with the Panel's WebContents.
    // which would also handle functionality like long-press-to-paste.
    private class InterceptNavigationDelegateImpl extends InterceptNavigationDelegate {
        final ExternalNavigationHandler mExternalNavHandler;

        public InterceptNavigationDelegateImpl() {
            Tab tab = mCurrentTabSupplier.get();
            mExternalNavHandler =
                    (tab != null && tab.getWebContents() != null)
                            ? new ExternalNavigationHandler(new ExternalNavigationDelegateImpl(tab))
                            : null;
        }

        @Override
        public boolean shouldIgnoreNavigation(
                NavigationHandle navigationHandle,
                GURL escapedUrl,
                boolean hiddenCrossFrame,
                boolean isSandboxedFrame) {
            // If either of the required params for the delegate are null, do not call the
            // delegate and ignore the navigation.
            if (mExternalNavHandler == null || navigationHandle == null) return true;
            return !mContentDelegate.shouldInterceptNavigation(
                    mExternalNavHandler,
                    escapedUrl,
                    navigationHandle.pageTransition(),
                    navigationHandle.isRedirect(),
                    navigationHandle.hasUserGesture(),
                    navigationHandle.isRendererInitiated(),
                    navigationHandle.getReferrerUrl(),
                    navigationHandle.isInPrimaryMainFrame(),
                    navigationHandle.isExternalProtocol());
        }

        @Override
        public GURL handleSubframeExternalProtocol(
                GURL escapedUrl,
                @PageTransition int transition,
                boolean hasUserGesture,
                Origin initiatorOrigin) {
            mContentDelegate.shouldInterceptNavigation(
                    mExternalNavHandler,
                    escapedUrl,
                    transition,
                    /* isRedirect= */ false,
                    hasUserGesture,
                    /* isRendererInitiated= */ true,
                    GURL.emptyGURL()
                    /* referrerUrl= */ ,
                    /* isInPrimaryMainFrame= */ false,
                    /* isExternalProtocol= */ true);
            return null;
        }
    }

    // ============================================================================================
    // Constructor
    // ============================================================================================

    /**
     * @param contentDelegate An observer for events that occur on this content. If null is passed
     *     for this parameter, the default one will be used.
     * @param progressObserver An observer for progress related events.
     * @param activity The {@link Activity} that contains this object.
     * @param profile The Profile associated with the OverlayPanel.
     * @param barHeight The height of the bar at the top of the OverlayPanel in dp.
     * @param compositorViewHolder The {@link CompositorViewHolder} for the current activity.
     * @param windowAndroid The {@link WindowAndroid} for the current activity.
     * @param currentTabSupplier Supplies the current activity {@link Tab}.
     */
    public OverlayPanelContent(
            @NonNull OverlayPanelContentDelegate contentDelegate,
            @NonNull OverlayPanelContentProgressObserver progressObserver,
            @NonNull Activity activity,
            @NonNull Profile profile,
            float barHeight,
            @NonNull ViewGroup compositorViewHolder,
            @NonNull WindowAndroid windowAndroid,
            @NonNull Supplier<Tab> currentTabSupplier) {
        mNativeOverlayPanelContentPtr = OverlayPanelContentJni.get().init(OverlayPanelContent.this);
        mContentDelegate = contentDelegate;
        mProgressObserver = progressObserver;
        mActivity = activity;
        mProfile = profile;
        mBarHeightPx = (int) (barHeight * mActivity.getResources().getDisplayMetrics().density);
        mCompositorViewHolder = compositorViewHolder;
        mWindowAndroid = windowAndroid;
        mCurrentTabSupplier = currentTabSupplier;

        mWebContentsDelegate =
                new WebContentsDelegateAndroid() {
                    private boolean mIsFullscreen;

                    @Override
                    public void loadingStateChanged(boolean shouldShowLoadingUI) {
                        boolean isLoading = mWebContents != null && mWebContents.isLoading();
                        if (isLoading) {
                            mProgressObserver.onProgressBarStarted();
                        } else {
                            mProgressObserver.onProgressBarFinished();
                        }
                    }

                    @Override
                    public void visibleSSLStateChanged() {
                        mContentDelegate.onSSLStateUpdated();
                    }

                    @Override
                    public void enterFullscreenModeForTab(
                            boolean prefersNavigationBar, boolean prefersStatusBar) {
                        mIsFullscreen = true;
                    }

                    @Override
                    public void exitFullscreenModeForTab() {
                        mIsFullscreen = false;
                    }

                    @Override
                    public boolean isFullscreenForTabOrPending() {
                        return mIsFullscreen;
                    }

                    @Override
                    public boolean shouldCreateWebContents(GURL targetUrl) {
                        return false;
                    }

                    @Override
                    public int getTopControlsHeight() {
                        return (int) (mBarHeightPx / mWindowAndroid.getDisplay().getDipScale());
                    }

                    @Override
                    public int getBottomControlsHeight() {
                        return 0;
                    }
                };
    }

    // ============================================================================================
    // WebContents related
    // ============================================================================================

    /**
     * Load a URL; this will trigger creation of a new WebContents if being loaded immediately,
     * otherwise one is created when the panel's content becomes visible.
     * @param url The URL that should be loaded.
     * @param shouldLoadImmediately If a URL should be loaded immediately or wait until visibility
     *        changes.
     */
    public void loadUrl(String url, boolean shouldLoadImmediately) {
        mPendingUrl = null;

        if (!shouldLoadImmediately) {
            mPendingUrl = url;
        } else {
            createNewWebContents();
            mLoadedUrl = url;
            mDidStartLoadingUrl = true;
            mIsProcessingPendingNavigation = true;
            mWebContents.getNavigationController().loadUrl(new LoadUrlParams(url));
        }
    }

    /**
     * Whether we should reuse any existing WebContents instead of deleting and recreating.
     * @param reuse {@code true} if we want to reuse the WebContents.
     */
    public void setReuseWebContents(boolean reuse) {
        mShouldReuseWebContents = reuse;
    }

    /**
     * Call this when a loadUrl request has failed to notify the panel that the WebContents can
     * be reused.  See crbug.com/682953 for details.
     */
    void onLoadUrlFailed() {
        setReuseWebContents(true);
    }

    /**
     * Set the desired size of the underlying {@link ContentView}. This is determined
     * by the {@link OverlayPanel} before the creation of the content view.
     * @param width The width of the content view.
     * @param height The height of the content view.
     * @param subtractBarHeight if {@code true} view height should be smaller by {@code mBarHeight}.
     */
    void setContentViewSize(int width, int height, boolean subtractBarHeight) {
        mContentViewWidth = width;
        mContentViewHeight = height;
        mSubtractBarHeight = subtractBarHeight;
    }

    /** Makes the content visible, causing it to be rendered. */
    public void showContent() {
        setVisibility(true);
    }

    /**
     * Sets the top offset of the overlay panel that varies as the panel state changes.
     * @param offset Top offset in pixel.
     */
    public void setPanelTopOffset(int offset) {
        mPanelTopOffsetPx = offset;
    }

    /** Create a new WebContents that will be managed by this panel. */
    private void createNewWebContents() {
        if (mWebContents != null) {
            // If the WebContents has already been created, but never used,
            // then there's no need to create a new one.
            if (!mDidStartLoadingUrl || mShouldReuseWebContents) return;

            destroyWebContents();
        }

        // Creates an initially hidden WebContents which gets shown when the panel is opened.
        mWebContents = WebContentsFactory.createWebContents(mProfile, true, false);

        ContentView cv = ContentView.createContentView(mActivity, mWebContents);
        if (mContentViewWidth != 0 || mContentViewHeight != 0) {
            int width =
                    mContentViewWidth == 0
                            ? ContentView.DEFAULT_MEASURE_SPEC
                            : MeasureSpec.makeMeasureSpec(mContentViewWidth, MeasureSpec.EXACTLY);
            int height =
                    mContentViewHeight == 0
                            ? ContentView.DEFAULT_MEASURE_SPEC
                            : MeasureSpec.makeMeasureSpec(mContentViewHeight, MeasureSpec.EXACTLY);
            cv.setDesiredMeasureSpec(width, height);
        }

        OverlayViewDelegate delegate = new OverlayViewDelegate(cv);
        mWebContents.setDelegates(
                VersionInfo.getProductVersion(),
                delegate,
                cv,
                mWindowAndroid,
                WebContents.createDefaultInternalsHolder());
        ContentUtils.setUserAgentOverride(mWebContents, /* overrideInNewTabs= */ false);

        // Transfers the ownership of the WebContents to the native OverlayPanelContent.
        OverlayPanelContentJni.get()
                .setWebContents(
                        mNativeOverlayPanelContentPtr,
                        OverlayPanelContent.this,
                        mWebContents,
                        mWebContentsDelegate);

        mWebContentsObserver =
                new WebContentsObserver(mWebContents) {
                    @Override
                    public void didStartLoading(GURL url) {
                        mContentDelegate.onContentLoadStarted();
                    }

                    @Override
                    public void loadProgressChanged(float progress) {
                        mProgressObserver.onProgressBarUpdated(progress);
                    }

                    @Override
                    public void navigationEntryCommitted(LoadCommittedDetails details) {
                        mContentDelegate.onNavigationEntryCommitted();
                    }

                    @Override
                    public void didStartNavigationInPrimaryMainFrame(NavigationHandle navigation) {
                        if (!navigation.isSameDocument()) {
                            String url = navigation.getUrl().getSpec();
                            mContentDelegate.onMainFrameLoadStarted(
                                    url, !TextUtils.equals(url, mLoadedUrl));
                        }
                    }

                    @Override
                    public void titleWasSet(String title) {
                        mContentDelegate.onTitleUpdated(title);
                    }

                    @Override
                    public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
                        if (navigation.hasCommitted()) {
                            mIsProcessingPendingNavigation = false;
                            mContentDelegate.onMainFrameNavigation(
                                    navigation.getUrl().getSpec(),
                                    !TextUtils.equals(navigation.getUrl().getSpec(), mLoadedUrl),
                                    isHttpFailureCode(navigation.httpStatusCode()),
                                    navigation.isErrorPage());
                        }
                    }

                    @Override
                    public void didFirstVisuallyNonEmptyPaint() {
                        mContentDelegate.onFirstNonEmptyPaint();
                    }
                };

        mContainerView = cv;
        mInterceptNavigationDelegate = new InterceptNavigationDelegateImpl();
        OverlayPanelContentJni.get()
                .setInterceptNavigationDelegate(
                        mNativeOverlayPanelContentPtr,
                        OverlayPanelContent.this,
                        mInterceptNavigationDelegate,
                        mWebContents);

        mContentDelegate.onContentViewCreated();
        resizePanelContentView();
        mCompositorViewHolder.addView(mContainerView, 1);
    }

    /** Destroy this panel's WebContents. */
    private void destroyWebContents() {
        if (mWebContents != null) {
            mCompositorViewHolder.removeView(mContainerView);

            // Native destroy will call up to destroy the Java WebContents.
            OverlayPanelContentJni.get()
                    .destroyWebContents(mNativeOverlayPanelContentPtr, OverlayPanelContent.this);
            mWebContents = null;
            if (mWebContentsObserver != null) {
                mWebContentsObserver.destroy();
                mWebContentsObserver = null;
            }

            mDidStartLoadingUrl = false;
            mIsProcessingPendingNavigation = false;
            mShouldReuseWebContents = false;
        }
    }

    // ============================================================================================
    // Utilities
    // ============================================================================================

    /**
     * Calls updateBrowserControlsState on the WebContents.
     * @param areControlsHidden Whether the browser controls are hidden for the web contents. If
     *                          false, the web contents viewport always accounts for the controls.
     *                          Otherwise the web contents never accounts for them.
     */
    public void updateBrowserControlsState(boolean areControlsHidden) {
        OverlayPanelContentJni.get()
                .updateBrowserControlsState(
                        mNativeOverlayPanelContentPtr, OverlayPanelContent.this, areControlsHidden);
    }

    /**
     * @return Whether a pending navigation if being processed.
     */
    public boolean isProcessingPendingNavigation() {
        return mIsProcessingPendingNavigation;
    }

    /** Reset the content's scroll position to (0, 0). */
    public void resetContentViewScroll() {
        if (mWebContents != null) {
            mWebContents.getEventForwarder().scrollTo(0, 0);
        }
    }

    /**
     * @return The Y scroll position.
     */
    public float getContentVerticalScroll() {
        return mWebContents != null
                ? RenderCoordinates.fromWebContents(mWebContents).getScrollYPixInt()
                : -1.f;
    }

    /**
     * Sets the visibility of the Search Content View.
     * @param isVisible True to make it visible.
     */
    private void setVisibility(boolean isVisible) {
        if (mIsContentViewShowing == isVisible) return;

        mIsContentViewShowing = isVisible;

        if (isVisible) {
            // If the last call to loadUrl was specified to be delayed, load it now.
            if (!TextUtils.isEmpty(mPendingUrl)) loadUrl(mPendingUrl, true);

            // The WebContents is created with the search request, but if none was made we'll need
            // one in order to display an empty panel.
            if (mWebContents == null) createNewWebContents();

            // NOTE(pedrosimonetti): Calling onShow() on the WebContents will cause the page
            // to be rendered. This has a side effect of causing the page to be included in
            // your Web History (if enabled). For this reason, onShow() should only be called
            // when we know for sure the page will be seen by the user.
            if (mWebContents != null) mWebContents.onShow();

            mContentDelegate.onContentViewSeen();
        } else {
            if (mWebContents != null) mWebContents.onHide();
        }

        mContentDelegate.onVisibilityChanged(isVisible);
    }

    /**
     * @return Whether the given HTTP result code represents a failure or not.
     */
    private static boolean isHttpFailureCode(int httpResultCode) {
        return httpResultCode <= 0 || httpResultCode >= 400;
    }

    /**
     * @return true if the content is visible on the page.
     */
    public boolean isContentShowing() {
        return mIsContentViewShowing;
    }

    // ============================================================================================
    // Methods for managing this panel's WebContents.
    // ============================================================================================

    /** Reset this object's native pointer to 0; */
    @CalledByNative
    private void clearNativePanelContentPtr() {
        assert mNativeOverlayPanelContentPtr != 0;
        mNativeOverlayPanelContentPtr = 0;
    }

    /**
     * @return The associated {@link WebContents}.
     */
    public WebContents getWebContents() {
        return mWebContents;
    }

    /**
     * @return The associated {@link ContentView}.
     */
    public ViewGroup getContainerView() {
        return mContainerView;
    }

    void resizePanelContentView() {
        WebContents webContents = getWebContents();
        if (webContents == null) return;
        int viewHeight = mContentViewHeight - (mSubtractBarHeight ? mBarHeightPx : 0);
        OverlayPanelContentJni.get()
                .onPhysicalBackingSizeChanged(
                        mNativeOverlayPanelContentPtr,
                        OverlayPanelContent.this,
                        webContents,
                        mContentViewWidth,
                        viewHeight);
        mWebContents.setSize(mContentViewWidth, viewHeight);
    }

    /**
     * Remove the list history entry from this panel if it was within a certain timeframe.
     * @param historyUrl The URL to remove.
     * @param urlTimeMs The time the URL was navigated to.
     */
    public void removeLastHistoryEntry(String historyUrl, long urlTimeMs) {
        OverlayPanelContentJni.get()
                .removeLastHistoryEntry(
                        mNativeOverlayPanelContentPtr,
                        OverlayPanelContent.this,
                        historyUrl,
                        urlTimeMs);
    }

    /** Destroy the native component of this class. */
    @VisibleForTesting
    public void destroy() {
        if (mWebContents != null) destroyWebContents();

        // Tests will not create the native pointer, so we need to check if it's not zero
        // otherwise calling OverlayPanelContentJni.get().destroy with zero will make Chrome crash.
        if (mNativeOverlayPanelContentPtr != 0L) {
            OverlayPanelContentJni.get()
                    .destroy(mNativeOverlayPanelContentPtr, OverlayPanelContent.this);
        }
    }

    public InterceptNavigationDelegate getInterceptNavigationDelegateForTesting() {
        return mInterceptNavigationDelegate;
    }

    @NativeMethods
    interface Natives {
        // Native calls.
        long init(OverlayPanelContent caller);

        void destroy(long nativeOverlayPanelContent, OverlayPanelContent caller);

        void removeLastHistoryEntry(
                long nativeOverlayPanelContent,
                OverlayPanelContent caller,
                String historyUrl,
                long urlTimeMs);

        void onPhysicalBackingSizeChanged(
                long nativeOverlayPanelContent,
                OverlayPanelContent caller,
                WebContents webContents,
                int width,
                int height);

        void setWebContents(
                long nativeOverlayPanelContent,
                OverlayPanelContent caller,
                WebContents webContents,
                WebContentsDelegateAndroid delegate);

        void destroyWebContents(long nativeOverlayPanelContent, OverlayPanelContent caller);

        void setInterceptNavigationDelegate(
                long nativeOverlayPanelContent,
                OverlayPanelContent caller,
                InterceptNavigationDelegate delegate,
                WebContents webContents);

        void updateBrowserControlsState(
                long nativeOverlayPanelContent,
                OverlayPanelContent caller,
                boolean areControlsHidden);
    }
}