Commit 2496c85c authored by Changwan Ryu's avatar Changwan Ryu Committed by Commit Bot

[WebView] Add display cutout controller for P+

Design doc: http://go/webview-display-cutout
(Google internal)

This is the core logic for display cutout support in WebView.

We cannot get the onApplyWindowInsets() for P, Q, R,
and sometimes view signals should update safe area insets, so
we are currently triggering window inset updates on certain
view callbacks.

https://crrev.com/c/chromium/src/+/2299461 is the next CL
that has more information on how this can be integrated into
AwContents.

Once the controller sets the insets and pass it to
WebContents#setDisplayCutoutSafeArea(insets), then the
webpages can use these values as safe-area-inset-*
CSS properties. Note that these CSS values will be
automatically updated when we set the values again, and set to
zero on navigation away from the current page.

Tested: AwDisplayCutoutControllerTest, also tested manually.
Bug: 1094366
Change-Id: I53461689ca065651d382ad2bb5c4cd1d09591df4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2298728Reviewed-by: default avatarBo <boliu@chromium.org>
Commit-Queue: Changwan Ryu <changwan@chromium.org>
Cr-Commit-Position: refs/heads/master@{#798948}
parent 9a20a5cc
......@@ -465,6 +465,7 @@ android_library("browser_java") {
"java/src/org/chromium/android_webview/AwDataDirLock.java",
"java/src/org/chromium/android_webview/AwDebug.java",
"java/src/org/chromium/android_webview/AwDevToolsServer.java",
"java/src/org/chromium/android_webview/AwDisplayCutoutController.java",
"java/src/org/chromium/android_webview/AwFeatureList.java",
"java/src/org/chromium/android_webview/AwFormDatabase.java",
"java/src/org/chromium/android_webview/AwGeolocationPermissions.java",
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// 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.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.os.Build;
import android.view.DisplayCutout;
import android.view.View;
import android.view.WindowInsets;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Log;
import org.chromium.base.annotations.VerifiesOnP;
/**
* Display cutout controller for WebView.
*
* This object should be constructed in WebView's constructor to support set listener logic for
* Android P and above.
*/
@TargetApi(Build.VERSION_CODES.P)
@VerifiesOnP
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();
/** @return The display width. */
int getDisplayWidth();
/** @return The display height. */
int getDisplayHeight();
/**
* 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;
// Reuse these structures to minimize memory impact.
private static int[] sCachedLocationOnScreen = {0, 0};
private Insets mCachedViewInsets = new Insets();
private Rect mCachedViewRect = new Rect();
private Rect mCachedWindowRect = new Rect();
private Rect mCachedDisplayRect = new Rect();
private Matrix mCachedMatrix = new Matrix();
/**
* 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(https://crbug.com/1094366): 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(https://crbug.com/1094366): 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) {
// Copy such that we can log the original value.
mCachedViewInsets.set(displayCutoutInsets);
getViewRectOnScreen(mContainerView, mCachedViewRect);
getViewRectOnScreen(mContainerView.getRootView(), mCachedWindowRect);
// Get display coordinates.
int displayWidth = mDelegate.getDisplayWidth();
int displayHeight = mDelegate.getDisplayHeight();
mCachedDisplayRect.set(0, 0, displayWidth, displayHeight);
float dipScale = mDelegate.getDipScale();
if (!mCachedViewRect.equals(mCachedDisplayRect)) {
// We apply window insets only when webview is occupying the entire window and display.
// Checking the window rect is more complicated and therefore not doing it for now, but
// there can still be cases where the window is a bit off.
if (DEBUG) {
Log.i(TAG, "WebView is not occupying the entire screen, so no insets applied.");
}
mCachedViewInsets.set(0, 0, 0, 0);
} else if (!mCachedViewRect.equals(mCachedWindowRect)) {
if (DEBUG) {
Log.i(TAG, "WebView is not occupying the entire window, so no insets applied.");
}
mCachedViewInsets.set(0, 0, 0, 0);
} else if (hasTransform()) {
if (DEBUG) {
Log.i(TAG, "WebView is rotated or scaled, so no insets applied.");
}
mCachedViewInsets.set(0, 0, 0, 0);
} else {
// We only apply this logic when webview is occupying the entire screen.
adjustInsetsForScale(mCachedViewInsets, dipScale);
}
if (DEBUG) {
Log.i(TAG,
"onApplyWindowInsetsInternal. displayCutoutInsets: " + displayCutoutInsets
+ ", view rect: " + mCachedViewRect + ", display rect: "
+ mCachedDisplayRect + ", window rect: " + mCachedWindowRect
+ ", dip scale: " + dipScale + ", viewInsets: " + mCachedViewInsets);
}
mDelegate.setDisplayCutoutSafeArea(mCachedViewInsets);
}
private static void getViewRectOnScreen(View view, Rect rect) {
if (view == null) {
rect.set(0, 0, 0, 0);
return;
}
view.getLocationOnScreen(sCachedLocationOnScreen);
int width = view.getMeasuredWidth();
int height = view.getMeasuredHeight();
rect.set(sCachedLocationOnScreen[0], sCachedLocationOnScreen[1],
sCachedLocationOnScreen[0] + width, sCachedLocationOnScreen[1] + height);
}
@SuppressLint("NewApi") // need this exception since we will try using Q API in P
private boolean hasTransform() {
mCachedMatrix.reset(); // set to identity
// Check if a view coordinates transforms to screen coordinates that is not an identity
// matrix, which means that view is rotated or scaled in regards to the screen.
// This API got hidden from L, and readded in API 29 (Q). It seems that we can call this
// on P most of the time, but adding try-catch just in case.
try {
mContainerView.transformMatrixToGlobal(mCachedMatrix);
} catch (Throwable e) {
return true;
}
return !mCachedMatrix.isIdentity();
}
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);
}
}
\ No newline at end of file
// Copyright 2020 The Chromium Authors. All rights reserved.
// 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.robolectric;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.when;
import android.content.Context;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.view.DisplayCutout;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import androidx.test.filters.SmallTest;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowLog;
import org.chromium.android_webview.AwDisplayCutoutController;
import org.chromium.android_webview.AwDisplayCutoutController.Insets;
import org.chromium.base.Log;
import org.chromium.base.test.util.Feature;
import org.chromium.testing.local.LocalRobolectricTestRunner;
/**
* JUnit tests for AwDisplayCutoutController.
*/
@RunWith(LocalRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class AwDisplayCutoutControllerTest {
private static final String TAG = "DisplayCutoutTest";
private static final boolean DEBUG = false;
private InOrder mInOrder;
private Context mContext;
@Mock
private AwDisplayCutoutController.Delegate mDelegate;
@Mock
private WindowInsets mWindowInsets;
@Mock
private DisplayCutout mDisplayCutout;
@Mock
private View mView;
@Mock
private View mAnotherView;
@Mock
private ViewGroup mParentView;
@Mock
private ViewGroup mRootView;
private View.OnApplyWindowInsetsListener mListener;
private int[] mLocationOnScreen = {0, 0};
private int mViewWidth;
private int mViewHeight;
private Matrix mGlobalTransformMatrix;
private float mDipScale;
private int mDisplayWidth;
private int mDisplayHeight;
private AwDisplayCutoutController mController;
public AwDisplayCutoutControllerTest() {
if (DEBUG) ShadowLog.stream = System.out; // allows logging
}
@Before
public void setUp() {
if (DEBUG) Log.i(TAG, "setUp");
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
// Set up default values.
setWindowInsets(new Rect(20, 40, 60, 80));
mDipScale = 2.0f;
mViewWidth = 300;
mViewHeight = 400;
mDisplayWidth = 300;
mDisplayHeight = 400;
mGlobalTransformMatrix = new Matrix(); // identity matrix
// Set up the view.
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
int[] loc = (int[]) (invocation.getArguments()[0]);
loc[0] = mLocationOnScreen[0];
loc[1] = mLocationOnScreen[1];
return null;
}
})
.when(mView)
.getLocationOnScreen(any(int[].class));
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
mListener = (View.OnApplyWindowInsetsListener) (invocation.getArguments()[0]);
return null;
}
})
.when(mView)
.setOnApplyWindowInsetsListener(any(View.OnApplyWindowInsetsListener.class));
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
mListener.onApplyWindowInsets(mView, mWindowInsets);
return null;
}
})
.when(mView)
.requestApplyInsets();
when(mView.getMeasuredWidth()).thenReturn(mViewWidth);
when(mView.getMeasuredHeight()).thenReturn(mViewHeight);
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
Matrix matrix = (Matrix) (invocation.getArguments()[0]);
matrix.set(mGlobalTransformMatrix);
return null;
}
})
.when(mView)
.transformMatrixToGlobal(any(Matrix.class));
// Set up the root view.
doAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
int[] loc = (int[]) (invocation.getArguments()[0]);
loc[0] = mLocationOnScreen[0];
loc[1] = mLocationOnScreen[1];
return null;
}
})
.when(mRootView)
.getLocationOnScreen(any(int[].class));
when(mRootView.getMeasuredWidth()).thenReturn(mViewWidth);
when(mRootView.getMeasuredHeight()).thenReturn(mViewHeight);
when(mView.getRootView()).thenReturn(mRootView);
// Set up the delegate.
when(mDelegate.getDipScale()).thenReturn(mDipScale);
when(mDelegate.getDisplayWidth()).thenReturn(mDisplayWidth);
when(mDelegate.getDisplayHeight()).thenReturn(mDisplayHeight);
mInOrder = inOrder(mDelegate, mView, mAnotherView);
mController = new AwDisplayCutoutController(mDelegate, mView);
mInOrder.verify(mView).setOnApplyWindowInsetsListener(
any(View.OnApplyWindowInsetsListener.class));
mInOrder.verifyNoMoreInteractions();
}
private void setWindowInsets(Rect insets) {
when(mDisplayCutout.getSafeInsetLeft()).thenReturn(insets.left);
when(mDisplayCutout.getSafeInsetTop()).thenReturn(insets.top);
when(mDisplayCutout.getSafeInsetRight()).thenReturn(insets.right);
when(mDisplayCutout.getSafeInsetBottom()).thenReturn(insets.bottom);
// Note that prior to Android Q, there is no way to build WindowInsets.
when(mWindowInsets.getDisplayCutout()).thenReturn(mDisplayCutout);
}
@After
public void tearDown() {
if (DEBUG) Log.i(TAG, "tearDown");
mInOrder.verifyNoMoreInteractions();
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnApplyWindowInsets() {
mController.onApplyWindowInsets(mWindowInsets);
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).getDipScale();
// Note that DIP of 2.0 is applied, so the values are halved.
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(10, 20, 30, 40)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnApplyWindowInsets_NotOccupyingFullDisplay() {
// View is not occupying the entire display, so no insets applied.
when(mView.getMeasuredHeight()).thenReturn(mDisplayHeight / 2);
mController.onApplyWindowInsets(mWindowInsets);
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(0, 0, 0, 0)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnApplyWindowInsets_NotOccupyingFullWindow() {
// View is not occupying the entire window, so no insets applied.
when(mRootView.getMeasuredHeight()).thenReturn(mViewHeight / 2);
mController.onApplyWindowInsets(mWindowInsets);
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(0, 0, 0, 0)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnApplyWindowInsets_ParentLayoutRotated() {
mGlobalTransformMatrix.postRotate(30.0f);
mController.onApplyWindowInsets(mWindowInsets);
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(0, 0, 0, 0)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnSizeChanged() {
mController.onSizeChanged();
mInOrder.verify(mView).requestApplyInsets();
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).getDipScale();
// Note that DIP of 2.0 is applied, so the values are halved.
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(10, 20, 30, 40)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testOnAttachedToWindow() {
mController.onAttachedToWindow();
mInOrder.verify(mView).requestApplyInsets();
mInOrder.verify(mView).getLocationOnScreen(any(int[].class));
mInOrder.verify(mView).getMeasuredWidth();
mInOrder.verify(mView).getMeasuredHeight();
mInOrder.verify(mDelegate).getDisplayWidth();
mInOrder.verify(mDelegate).getDisplayHeight();
mInOrder.verify(mDelegate).getDipScale();
// Note that DIP of 2.0 is applied, so the values are halved.
mInOrder.verify(mDelegate).setDisplayCutoutSafeArea(eq(new Insets(10, 20, 30, 40)));
}
@Test
@SmallTest
@Feature({"AndroidWebView"})
public void testChangeContainerView_doesNotTriggerOriginalView() {
mController.registerContainerView(mAnotherView);
mInOrder.verify(mAnotherView)
.setOnApplyWindowInsetsListener(any(View.OnApplyWindowInsetsListener.class));
// Switching to another container view.
mController.setCurrentContainerView(mAnotherView);
mInOrder.verify(mAnotherView).requestApplyInsets();
mController.onAttachedToWindow();
// Note that mView methods are not triggered.
mInOrder.verify(mAnotherView).requestApplyInsets();
}
}
\ No newline at end of file
......@@ -498,6 +498,7 @@ generate_jni("android_webview_unittests_jni") {
# robolectric tests
junit_binary("android_webview_junit_tests") {
sources = [
"../junit/src/org/chromium/android_webview/robolectric/AwDisplayCutoutControllerTest.java",
"../junit/src/org/chromium/android_webview/robolectric/AwLayoutSizerTest.java",
"../junit/src/org/chromium/android_webview/robolectric/AwScrollOffsetManagerTest.java",
"../junit/src/org/chromium/android_webview/robolectric/FindAddressTest.java",
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment