chromium/chrome/browser/ui/android/edge_to_edge/internal/java/src/org/chromium/chrome/browser/ui/edge_to_edge/EdgeToEdgeControllerImpl.java

// Copyright 2023 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.ui.edge_to_edge;

import android.app.Activity;
import android.graphics.Rect;
import android.os.Build.VERSION_CODES;
import android.view.View;

import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;

import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.fullscreen.FullscreenOptions;
import org.chromium.chrome.browser.layouts.LayoutManager;
import org.chromium.chrome.browser.layouts.LayoutStateProvider;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabSupplierObserver;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.InsetObserver.WindowInsetsConsumer;
import org.chromium.ui.base.WindowAndroid;

/**
 * Controls use of the Android Edge To Edge feature that allows an App to draw benieth the Status
 * and Navigation Bars. For Chrome, we intentend to sometimes draw under the Nav Bar but not the
 * Status Bar.
 */
@RequiresApi(VERSION_CODES.R)
public class EdgeToEdgeControllerImpl
        implements EdgeToEdgeController,
                BrowserControlsStateProvider.Observer,
                LayoutStateProvider.LayoutStateObserver,
                FullscreenManager.Observer {
    private static final String TAG = "E2E_ControllerImpl";

    /** The outermost view in our view hierarchy that is identified with a resource ID. */
    private static final int ROOT_UI_VIEW_ID = android.R.id.content;

    private final @NonNull Activity mActivity;
    private final @NonNull WindowAndroid mWindowAndroid;
    private final @NonNull TabSupplierObserver mTabSupplierObserver;
    private final ObserverList<EdgeToEdgePadAdjuster> mPadAdjusters = new ObserverList<>();
    private final ObserverList<ChangeObserver> mEdgeChangeObservers = new ObserverList<>();
    private final @NonNull TabObserver mTabObserver;
    private final BrowserControlsStateProvider mBrowserControlsStateProvider;
    private final LayoutManager mLayoutManager;
    private final FullscreenManager mFullscreenManager;

    // Cached rects used for adding under fullscreen.
    private final Rect mCachedWindowVisibleRect = new Rect();
    private final Rect mCachedContentVisibleRect = new Rect();

    /** Multiplier to convert from pixels to DPs. */
    private final float mPxToDp;

    private @NonNull EdgeToEdgeOSWrapper mEdgeToEdgeOSWrapper;

    private Tab mCurrentTab;
    private WebContentsObserver mWebContentsObserver;

    /**
     * Whether the system is drawing "toEdge" (i.e. the edge-to-edge wrapper has no bottom padding).
     * This could be due to the current page being opted into edge-to-edge, or a partial
     * edge-to-edge with the bottom chin present.
     */
    private boolean mIsDrawingToEdge;

    /**
     * Whether the edge-to-edge feature is enabled and the current tab content is showing
     * edge-to-edge. This could be from the web content being opted in, or from the tab showing a
     * native page that supports edge-to-edge.
     */
    private boolean mIsPageOptedIntoEdgeToEdge;

    private InsetObserver mInsetObserver;
    private @NonNull Insets mSystemInsets;
    private Insets mAppliedContentViewPadding;
    private @Nullable Insets mKeyboardInsets;
    private @Nullable WindowInsetsConsumer mWindowInsetsConsumer;
    private boolean mBottomControlsAreVisible;
    private int mBottomControlsHeight;

    /**
     * Creates an implementation of the EdgeToEdgeController that will use the Android APIs to allow
     * drawing under the System Gesture Navigation Bar.
     *
     * @param activity The activity to update to allow drawing under System Bars.
     * @param windowAndroid The current {@link WindowAndroid} to allow drawing under System Bars.
     * @param tabObservableSupplier A supplier for Tab changes so this implementation can adjust
     *     whether to draw under or not for each page.
     * @param edgeToEdgeOSWrapper An optional wrapper for OS calls for testing etc.
     * @param browserControlsStateProvider Provides the state of the BrowserControls for Totally
     *     Edge to Edge.
     * @param layoutManager The {@link LayoutManager} for checking the active layout type.
     * @param fullscreenManager The {@link FullscreenManager} for checking the fullscreen state.
     */
    public EdgeToEdgeControllerImpl(
            Activity activity,
            WindowAndroid windowAndroid,
            ObservableSupplier<Tab> tabObservableSupplier,
            @Nullable EdgeToEdgeOSWrapper edgeToEdgeOSWrapper,
            BrowserControlsStateProvider browserControlsStateProvider,
            LayoutManager layoutManager,
            FullscreenManager fullscreenManager) {
        mActivity = activity;
        mWindowAndroid = windowAndroid;
        mEdgeToEdgeOSWrapper =
                edgeToEdgeOSWrapper == null ? new EdgeToEdgeOSWrapperImpl() : edgeToEdgeOSWrapper;
        mPxToDp = 1.f / mActivity.getResources().getDisplayMetrics().density;
        mTabSupplierObserver =
                new TabSupplierObserver(tabObservableSupplier) {
                    @Override
                    protected void onObservingDifferentTab(Tab tab) {
                        onTabSwitched(tab);
                    }
                };
        mTabObserver =
                new EmptyTabObserver() {
                    @Override
                    public void onWebContentsSwapped(
                            Tab tab, boolean didStartLoad, boolean didFinishLoad) {
                        updateWebContentsObserver(tab);
                    }

                    @Override
                    public void onContentChanged(Tab tab) {
                        assert tab.getWebContents() != null
                                : "onContentChanged called on tab w/o WebContents: "
                                        + tab.getTitle();
                        updateWebContentsObserver(tab);
                    }
                };
        mInsetObserver = mWindowAndroid.getInsetObserver();
        mBrowserControlsStateProvider = browserControlsStateProvider;
        mBrowserControlsStateProvider.addObserver(this);
        mLayoutManager = layoutManager;
        mLayoutManager.addObserver(this);
        mFullscreenManager = fullscreenManager;
        mFullscreenManager.addObserver(this);

        mWindowInsetsConsumer = this::handleWindowInsets;
        mInsetObserver.addInsetsConsumer(mWindowInsetsConsumer);

        assert mInsetObserver.getLastRawWindowInsets() != null
                : "The inset observer should have non-null insets by the time the"
                        + " EdgeToEdgeControllerImpl is initialized.";
        mSystemInsets = getSystemInsets(mInsetObserver.getLastRawWindowInsets());
        mEdgeToEdgeOSWrapper.setDecorFitsSystemWindows(mActivity.getWindow(), false);
        drawToEdge(
                EdgeToEdgeUtils.isPageOptedIntoEdgeToEdge(mCurrentTab),
                /* changedWindowState= */ true);
    }

    @VisibleForTesting
    void onTabSwitched(@Nullable Tab tab) {
        if (mCurrentTab != null) mCurrentTab.removeObserver(mTabObserver);
        mCurrentTab = tab;
        if (tab != null) {
            tab.addObserver(mTabObserver);
            if (tab.getWebContents() != null) {
                updateWebContentsObserver(tab);
            }
        }

        drawToEdge(
                EdgeToEdgeUtils.isPageOptedIntoEdgeToEdge(mCurrentTab),
                /* changedWindowState= */ false);
    }

    @Override
    public void registerAdjuster(EdgeToEdgePadAdjuster adjuster) {
        mPadAdjusters.addObserver(adjuster);
        boolean shouldPad = shouldPadAdjusters();
        adjuster.overrideBottomInset(shouldPad ? mSystemInsets.bottom : 0);
    }

    @Override
    public void unregisterAdjuster(EdgeToEdgePadAdjuster adjuster) {
        mPadAdjusters.removeObserver(adjuster);
    }

    @Override
    public void registerObserver(ChangeObserver changeObserver) {
        mEdgeChangeObservers.addObserver(changeObserver);
    }

    @Override
    public void unregisterObserver(ChangeObserver changeObserver) {
        mEdgeChangeObservers.removeObserver(changeObserver);
    }

    @Override
    public int getBottomInset() {
        return isDrawingToEdge() ? (int) Math.ceil(mSystemInsets.bottom * mPxToDp) : 0;
    }

    @Override
    public int getBottomInsetPx() {
        return isDrawingToEdge() ? mSystemInsets.bottom : 0;
    }

    @Override
    public boolean isDrawingToEdge() {
        return mIsDrawingToEdge;
    }

    @Override
    public boolean isPageOptedIntoEdgeToEdge() {
        return mIsPageOptedIntoEdgeToEdge;
    }

    // BrowserControlsStateProvider.Observer

    @Override
    public void onControlsOffsetChanged(
            int topOffset,
            int topControlsMinHeightOffset,
            int bottomOffset,
            int bottomControlsMinHeightOffset,
            boolean needsAnimate,
            boolean isVisibilityForced) {
        updateBrowserControlsVisibility(
                mBottomControlsHeight > 0 && bottomOffset < mBottomControlsHeight);
    }

    @Override
    public void onBottomControlsHeightChanged(
            int bottomControlsHeight, int bottomControlsMinHeight) {
        // The bottom controls are shown / hidden from the user by changing the height, rather than
        // changing view visibility.
        mBottomControlsHeight = bottomControlsHeight;
        updateBrowserControlsVisibility(bottomControlsHeight > 0);
    }

    // LayoutStateProvider.LayoutStateObserver

    @Override
    public void onStartedShowing(int layoutType) {
        drawToEdge(mIsPageOptedIntoEdgeToEdge, false);
    }

    // FullscreenManager.Observer
    @Override
    public void onEnterFullscreen(Tab tab, FullscreenOptions options) {
        drawToEdge(mIsPageOptedIntoEdgeToEdge, /* changedWindowState= */ true);
    }

    @Override
    public void onExitFullscreen(Tab tab) {
        drawToEdge(mIsPageOptedIntoEdgeToEdge, /* changedWindowState= */ true);
    }

    private View getContentView() {
        return mActivity.findViewById(ROOT_UI_VIEW_ID);
    }

    private void updateBrowserControlsVisibility(boolean visible) {
        if (mBottomControlsAreVisible == visible) {
            return;
        }
        mBottomControlsAreVisible = visible;
        updatePadAdjusters();
    }

    /**
     * Updates our private WebContentsObserver member to point to the given Tab's WebContents.
     * Destroys any previous member.
     *
     * @param tab The {@link Tab} whose {@link WebContents} we want to observe.
     */
    private void updateWebContentsObserver(Tab tab) {
        if (mWebContentsObserver != null) mWebContentsObserver.destroy();
        mWebContentsObserver =
                new WebContentsObserver(tab.getWebContents()) {
                    @Override
                    public void viewportFitChanged(@WebContentsObserver.ViewportFitType int value) {
                        drawToEdge(
                                EdgeToEdgeUtils.isPageOptedIntoEdgeToEdge(mCurrentTab, value),
                                /* changedWindowState= */ false);
                    }
                };
        // TODO(https://crbug.com/1482559#c23) remove this logging by end of '23.
        Log.i(TAG, "E2E_Up Tab '%s'", tab.getTitle());
    }

    /**
     * Conditionally draws the given View ToEdge or ToNormal based on the {@code toEdge} param.
     *
     * @param pageOptedIntoEdgeToEdge Whether the page is opted into edge-to-edge.
     * @param changedWindowState Whether this method is called due to window state changed (e.g.
     *     windowInsets updated, window goes into fullscreen mode).
     */
    @VisibleForTesting
    void drawToEdge(boolean pageOptedIntoEdgeToEdge, boolean changedWindowState) {
        boolean shouldDrawToEdge =
                EdgeToEdgeUtils.shouldDrawToEdge(
                        pageOptedIntoEdgeToEdge,
                        mLayoutManager.getActiveLayoutType(),
                        mSystemInsets.bottom);
        boolean changedPageOptedIn = pageOptedIntoEdgeToEdge != mIsPageOptedIntoEdgeToEdge;
        boolean changedDrawToEdge = shouldDrawToEdge != mIsDrawingToEdge;
        mIsPageOptedIntoEdgeToEdge = pageOptedIntoEdgeToEdge;
        mIsDrawingToEdge = shouldDrawToEdge;

        if (changedPageOptedIn) {
            Log.v(
                    TAG,
                    "Switching %s",
                    (mIsPageOptedIntoEdgeToEdge
                            ? "Opted into EdgeToEdge"
                            : "Not opted into EdgeToEdge"));
        }

        if (changedDrawToEdge) {
            Log.v(TAG, "Switching %s", (mIsDrawingToEdge ? "ToEdge" : "ToNormal"));
        }

        if (changedPageOptedIn || changedDrawToEdge || changedWindowState) {
            adjustEdgePaddings();
            updatePadAdjusters();

            for (var observer : mEdgeChangeObservers) {
                observer.onToEdgeChange(
                        mSystemInsets.bottom, isDrawingToEdge(), isPageOptedIntoEdgeToEdge());
            }
        }
    }

    @NonNull
    @VisibleForTesting
    WindowInsetsCompat handleWindowInsets(View rootView, @NonNull WindowInsetsCompat windowInsets) {
        Insets newInsets = getSystemInsets(windowInsets);
        Insets newKeyboardInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime());

        if (!newInsets.equals(mSystemInsets)
                || !newKeyboardInsets.equals(mKeyboardInsets)
                || updateVisibilityRects(rootView)) {
            mSystemInsets = newInsets;
            mKeyboardInsets = newKeyboardInsets;

            // When a foldable goes to/from tablet mode we must reassess.
            // TODO(https://crbug.com/325356134) Find a cleaner check and remedy.
            mIsPageOptedIntoEdgeToEdge =
                    mIsPageOptedIntoEdgeToEdge
                            && EdgeToEdgeControllerFactory.isSupportedConfiguration(mActivity);
            // Note that we cannot #drawToEdge earlier since we need the system
            // insets.
            drawToEdge(mIsPageOptedIntoEdgeToEdge, /* changedWindowState= */ true);
        }
        return windowInsets;
    }

    private boolean updateVisibilityRects(View rootView) {
        Rect windowVisibleRect = new Rect();
        rootView.getWindowVisibleDisplayFrame(windowVisibleRect);

        Rect contentVisibleRect = new Rect();
        View contentView = getContentView();
        if (contentView != null) {
            contentView.getGlobalVisibleRect(contentVisibleRect);
            int[] locationOnScreen = new int[2];
            rootView.getLocationOnScreen(locationOnScreen);
            contentVisibleRect.offset(locationOnScreen[0], locationOnScreen[1]);
        }

        if (windowVisibleRect.equals(mCachedWindowVisibleRect)
                && contentVisibleRect.equals(mCachedContentVisibleRect)) {
            return false;
        }
        mCachedWindowVisibleRect.set(windowVisibleRect);
        mCachedContentVisibleRect.set(contentVisibleRect);
        return true;
    }

    /**
     * The {@link EdgeToEdgePadAdjuster}s should only be padded with an extra bottom inset if the
     * activity is currently in edge-to-edge, and if the adjusters aren't already positioned above
     * the system insets due to the keyboard or the bottom controls being visible.
     */
    private boolean shouldPadAdjusters() {
        // Never pad the adjusters if the keyboard is visible.
        if (mKeyboardInsets != null && mKeyboardInsets.bottom > 0) return false;

        // Never pad the adjusters if the bottom controls are visible.
        if (mBottomControlsAreVisible) return false;

        // Pad the adjusters if drawing to edge.
        return isDrawingToEdge();
    }

    private void updatePadAdjusters() {
        boolean shouldPad = shouldPadAdjusters();
        for (var adjuster : mPadAdjusters) {
            adjuster.overrideBottomInset(shouldPad ? mSystemInsets.bottom : 0);
        }
    }

    /**
     * Adjusts whether the given view draws ToEdge or ToNormal. The ability to draw under System
     * Bars should have already been set. This method only sets the padding of the view and
     * transparency of the Nav Bar, etc.
     */
    private void adjustEdgePaddings() {
        View contentView = getContentView();
        assert contentView != null : "Root view for Edge To Edge not found!";

        int topPadding = mSystemInsets.top;
        // Adjust the bottom padding to reflect whether ToEdge or ToNormal for the Gesture Nav Bar.
        // All the other edges need to be padded to prevent drawing under an edge that we
        // don't want drawn ToEdge (e.g. the Status Bar).
        int bottomPadding = mIsDrawingToEdge ? 0 : mSystemInsets.bottom;
        if (mKeyboardInsets != null && mKeyboardInsets.bottom > bottomPadding) {
            // If the keyboard is showing, change the bottom padding to account for the keyboard.
            // Clear the bottom inset used for the adjusters, since there are no missing bottom
            // system bars above the keyboard to compensate for.
            bottomPadding = mKeyboardInsets.bottom;
        }

        // In fullscreen mode, there are cases the content isn't being drawn under the system
        // bar (e.g. during multi-window mode). In this case, adjust the padding based on the
        // visibility rects. See https://crbug.com/359659885
        if (mFullscreenManager.getPersistentFullscreenMode()) {
            topPadding = Math.max(0, mCachedWindowVisibleRect.top - mCachedContentVisibleRect.top);
            bottomPadding =
                    Math.max(0, mCachedContentVisibleRect.bottom - mCachedWindowVisibleRect.bottom);
        }

        // Use Insets to store the paddings as it is immutable.
        Insets newPaddings =
                Insets.of(mSystemInsets.left, topPadding, mSystemInsets.right, bottomPadding);
        if (!newPaddings.equals(mAppliedContentViewPadding)) {
            mAppliedContentViewPadding = newPaddings;
            mEdgeToEdgeOSWrapper.setPadding(
                    contentView,
                    newPaddings.left,
                    newPaddings.top,
                    newPaddings.right,
                    newPaddings.bottom);
        }

        int bottomInsetOnSafeArea = mIsDrawingToEdge ? mSystemInsets.bottom : 0;
        mInsetObserver.updateBottomInsetForEdgeToEdge(bottomInsetOnSafeArea);
    }

    @CallSuper
    @Override
    public void destroy() {
        if (mWebContentsObserver != null) {
            mWebContentsObserver.destroy();
            mWebContentsObserver = null;
        }
        if (mCurrentTab != null) mCurrentTab.removeObserver(mTabObserver);
        mTabSupplierObserver.destroy();
        if (mInsetObserver != null) {
            mInsetObserver.removeInsetsConsumer(mWindowInsetsConsumer);
            mInsetObserver = null;
        }
        if (mBrowserControlsStateProvider != null) {
            mBrowserControlsStateProvider.removeObserver(this);
        }
        if (mLayoutManager != null) {
            mLayoutManager.removeObserver(this);
        }
        if (mFullscreenManager != null) {
            mFullscreenManager.removeObserver(this);
        }
    }

    public void setOsWrapperForTesting(EdgeToEdgeOSWrapper testOsWrapper) {
        mEdgeToEdgeOSWrapper = testOsWrapper;
    }

    @VisibleForTesting
    @Nullable
    WebContentsObserver getWebContentsObserver() {
        return mWebContentsObserver;
    }

    public void setIsOptedIntoEdgeToEdgeForTesting(boolean toEdge) {
        mIsPageOptedIntoEdgeToEdge = toEdge;
    }

    public void setIsDrawingToEdgeForTesting(boolean toEdge) {
        mIsDrawingToEdge = toEdge;
    }

    public @Nullable ChangeObserver getAnyChangeObserverForTesting() {
        return mEdgeChangeObservers.isEmpty() ? null : mEdgeChangeObservers.iterator().next();
    }

    void setSystemInsetsForTesting(Insets systemInsetsForTesting) {
        mSystemInsets = systemInsetsForTesting;
    }

    void setKeyboardInsetsForTesting(Insets keyboardInsetsForTesting) {
        mKeyboardInsets = keyboardInsetsForTesting;
    }

    private static Insets getSystemInsets(@NonNull WindowInsetsCompat windowInsets) {
        return windowInsets.getInsets(
                WindowInsetsCompat.Type.navigationBars() + WindowInsetsCompat.Type.statusBars());
    }
}