chromium/android_webview/java/src/org/chromium/android_webview/AwDisplayCutoutController.java

// Copyright 2020 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.android_webview;

import android.graphics.Rect;
import android.os.Build;
import android.view.DisplayCutout;
import android.view.View;
import android.view.WindowInsets;

import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;

import org.chromium.android_webview.common.Lifetime;
import org.chromium.base.Log;

/**
 * Display cutout controller for WebView.
 *
 * This object should be constructed in WebView's constructor to support set listener logic for
 * Android P and above.
 */
@Lifetime.WebView
@RequiresApi(Build.VERSION_CODES.P)
public class AwDisplayCutoutController {
    private static final boolean DEBUG = false;
    private static final String TAG = "DisplayCutout";

    /** This is a delegate that the embedder needs to implement. */
    public interface Delegate {
        /** @return The DIP scale. */
        float getDipScale();

        /**
         * Set display cutout safe area such that webpage can read safe-area-insets CSS properties.
         * Note that this can be called with the same parameter repeatedly, and the embedder needs
         * to check / throttle as necessary.
         *
         * @param insets A placeholder to store left, top, right, and bottom insets in regards to
         *               WebView. Note that DIP scale has been applied.
         */
        void setDisplayCutoutSafeArea(Insets insets);
    }

    /**
     * A placeholder for insets.
     *
     * android.graphics.Insets is available from Q, while we support display cutout from P and
     * above, so adding a new class.
     */
    public static final class Insets {
        public int left;
        public int top;
        public int right;
        public int bottom;

        public Insets() {}

        public Insets(int left, int top, int right, int bottom) {
            set(left, top, right, bottom);
        }

        public Rect toRect(Rect rect) {
            rect.set(left, top, right, bottom);
            return rect;
        }

        public void set(Insets insets) {
            left = insets.left;
            top = insets.top;
            right = insets.right;
            bottom = insets.bottom;
        }

        public void set(int left, int top, int right, int bottom) {
            this.left = left;
            this.top = top;
            this.right = right;
            this.bottom = bottom;
        }

        @Override
        public final boolean equals(Object o) {
            if (!(o instanceof Insets)) return false;
            Insets i = (Insets) o;
            return left == i.left && top == i.top && right == i.right && bottom == i.bottom;
        }

        @Override
        public final String toString() {
            return "Insets: (" + left + ", " + top + ")-(" + right + ", " + bottom + ")";
        }

        @Override
        public final int hashCode() {
            return 3 * left + 5 * top + 7 * right + 11 * bottom;
        }
    }

    private Delegate mDelegate;
    private View mContainerView;

    /**
     * Constructor for AwDisplayCutoutController.
     *
     * @param delegate The delegate.
     * @param containerView The container view (WebView).
     */
    public AwDisplayCutoutController(Delegate delegate, View containerView) {
        mDelegate = delegate;
        mContainerView = containerView;
        registerContainerView(containerView);
    }

    /**
     * Register a container view to listen to window insets.
     *
     * Note that you do not need to register the containerView.
     *
     * @param containerView A container View, such as fullscreen view.
     */
    public void registerContainerView(View containerView) {
        if (DEBUG) Log.i(TAG, "registerContainerView");
        // For Android P~R, we set the listener in WebView's constructor.
        // Once we set the listener, we will no longer get View#onApplyWindowInsets(WindowInsets).
        // If the app sets its own listener after WebView's constructor, then the app can override
        // our logic, which seems like a natural behavior.
        // For Android S, WebViewChromium can get onApplyWindowInsets(WindowInsets) call, so we do
        // not need to set the listener.
        // TODO(crbug.com/40699457): do not set listener and plumb WebViewChromium to handle
        // onApplyWindowInsets in S and above.
        containerView.setOnApplyWindowInsetsListener(
                new View.OnApplyWindowInsetsListener() {
                    @Override
                    public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
                        // Ignore if this is not the current container view.
                        if (view == mContainerView) {
                            return AwDisplayCutoutController.this.onApplyWindowInsets(insets);
                        } else {
                            if (DEBUG) Log.i(TAG, "Ignoring onApplyWindowInsets on View: " + view);
                            return insets;
                        }
                    }
                });
    }

    /**
     * Set the current container view.
     *
     * @param containerView The current container view.
     */
    public void setCurrentContainerView(View containerView) {
        if (DEBUG) Log.i(TAG, "setCurrentContainerView: " + containerView);
        mContainerView = containerView;
        // Ensure that we get new insets for the new container view.
        mContainerView.requestApplyInsets();
    }

    /**
     * Call this when window insets are first applied or changed.
     *
     * @see View#onApplyWindowInsets(WindowInsets)
     * @param insets The window (display) insets.
     */
    @VisibleForTesting
    public WindowInsets onApplyWindowInsets(final WindowInsets insets) {
        if (DEBUG) Log.i(TAG, "onApplyWindowInsets: " + insets.toString());
        // TODO(crbug.com/40699457): add a throttling logic.
        DisplayCutout cutout = insets.getDisplayCutout();
        // DisplayCutout can be null if there is no notch, or layoutInDisplayCutoutMode is DEFAULT
        // (before R) or consumed in the parent view.
        if (cutout != null) {
            Insets displayCutoutInsets =
                    new Insets(
                            cutout.getSafeInsetLeft(),
                            cutout.getSafeInsetTop(),
                            cutout.getSafeInsetRight(),
                            cutout.getSafeInsetBottom());
            onApplyWindowInsetsInternal(displayCutoutInsets);
        }
        return insets;
    }

    /**
     * Call this when window insets are first applied or changed.
     *
     * Similar to {@link onApplyWindowInsets(WindowInsets)}, but accepts
     * Rect as input.
     *
     * @param displayCutoutInsets Insets to store left, top, right, bottom insets.
     */
    @VisibleForTesting
    public void onApplyWindowInsetsInternal(final Insets displayCutoutInsets) {
        float dipScale = mDelegate.getDipScale();
        // We only apply this logic when webview is occupying the entire screen.
        adjustInsetsForScale(displayCutoutInsets, dipScale);

        if (DEBUG) {
            Log.i(
                    TAG,
                    "onApplyWindowInsetsInternal. insets: "
                            + displayCutoutInsets
                            + ", dip scale: "
                            + dipScale);
        }
        // Note that internally we apply this logic only when the display is in fullscreen mode.
        // See AwDisplayModeController for more details on how we check the fullscreen mode.
        mDelegate.setDisplayCutoutSafeArea(displayCutoutInsets);
    }

    private void onUpdateWindowInsets() {
        mContainerView.requestApplyInsets();
    }

    /** @see View#onSizeChanged(int, int, int, int) */
    public void onSizeChanged() {
        if (DEBUG) Log.i(TAG, "onSizeChanged");
        onUpdateWindowInsets();
    }

    /** @see View#onAttachedToWindow() */
    public void onAttachedToWindow() {
        if (DEBUG) Log.i(TAG, "onAttachedToWindow");
        onUpdateWindowInsets();
    }

    private static void adjustInsetsForScale(Insets insets, float dipScale) {
        insets.left = adjustOneInsetForScale(insets.left, dipScale);
        insets.top = adjustOneInsetForScale(insets.top, dipScale);
        insets.right = adjustOneInsetForScale(insets.right, dipScale);
        insets.bottom = adjustOneInsetForScale(insets.bottom, dipScale);
    }

    /**
     * Adjusts a WindowInset inset to a CSS pixel value.
     *
     * @param inset The inset as an integer.
     * @param dipScale The devices dip scale as an integer.
     * @return The CSS pixel value adjusted for scale.
     */
    private static int adjustOneInsetForScale(int inset, float dipScale) {
        return (int) Math.ceil(inset / dipScale);
    }
}