// Copyright 2022 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.features.partialcustomtab;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_FULL_SCREEN;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.InsetDrawable;
import android.os.Handler;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.AccelerateInterpolator;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.browser.customtabs.CustomTabsCallback;
import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.features.toolbar.CustomTabToolbar;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.fullscreen.FullscreenOptions;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.ui.base.ViewUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.BooleanSupplier;
/** Base class for PCCT size strategies implementations. */
public abstract class PartialCustomTabBaseStrategy extends CustomTabHeightStrategy
implements FullscreenManager.Observer {
private static boolean sDeviceSpecLogged;
protected final Activity mActivity;
protected final OnResizedCallback mOnResizedCallback;
protected final OnActivityLayoutCallback mOnActivityLayoutCallback;
protected final FullscreenManager mFullscreenManager;
protected final boolean mIsTablet;
protected final boolean mInteractWithBackground;
protected final int mCachedHandleHeight;
protected final PartialCustomTabVersionCompat mVersionCompat;
protected @Px int mDisplayHeight;
protected @Px int mDisplayWidth;
protected Runnable mPositionUpdater;
// Runnable finishing the activity after the exit animation. Non-null when PCCT is closing.
@Nullable protected Runnable mFinishRunnable;
protected @Px int mNavbarHeight;
protected @Px int mStatusbarHeight;
// The current height/width used to trigger onResizedCallback when it is resized.
protected int mHeight;
protected int mWidth;
protected CustomTabToolbar mToolbarView;
protected View mToolbarCoordinator;
protected int mToolbarColor;
protected int mToolbarCornerRadius;
protected PartialCustomTabHandleStrategyFactory mHandleStrategyFactory;
protected int mShadowOffset;
// Note: Do not use anywhere except in |onConfigurationChanged| as it might not be up-to-date.
protected boolean mIsInMultiWindowMode;
protected int mOrientation;
private final Callback<Integer> mVisibilityChangeObserver =
this::onToolbarContainerVisibilityChange;
private ValueAnimator mAnimator;
private Runnable mPostAnimationRunnable;
private BooleanSupplier mIsFullscreenForTesting;
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
// This should be kept in sync with the definition |CustomTabsPartialCustomTabType|
// in tools/metrics/histograms/enums.xml.
@IntDef({
PartialCustomTabType.NONE,
PartialCustomTabType.BOTTOM_SHEET,
PartialCustomTabType.SIDE_SHEET,
PartialCustomTabType.FULL_SIZE,
PartialCustomTabType.COUNT
})
@Retention(RetentionPolicy.SOURCE)
public @interface PartialCustomTabType {
int NONE = 0;
int BOTTOM_SHEET = 1;
int SIDE_SHEET = 2;
int FULL_SIZE = 3;
// Number of elements in the enum
int COUNT = 4;
}
// These values are persisted to logs. Entries should not be renumbered and
// numeric values should never be reused.
// This should be kept in sync with the definition |PcctDeviceSpec|
// in tools/metrics/histograms/enums.xml.
@IntDef({
DeviceSpec.LOWEND_NOPIP,
DeviceSpec.LOWEND_PIP,
DeviceSpec.HIGHEND_NOPIP,
DeviceSpec.HIGHEND_PIP
})
@Retention(RetentionPolicy.SOURCE)
@interface DeviceSpec {
int LOWEND_NOPIP = 0;
int LOWEND_PIP = 1;
int HIGHEND_NOPIP = 2;
int HIGHEND_PIP = 3;
// Number of elements in the enum
int COUNT = 4;
}
public PartialCustomTabBaseStrategy(
Activity activity,
BrowserServicesIntentDataProvider intentData,
OnResizedCallback onResizedCallback,
OnActivityLayoutCallback onActivityLayoutCallback,
FullscreenManager fullscreenManager,
boolean isTablet,
PartialCustomTabHandleStrategyFactory handleStrategyFactory) {
mActivity = activity;
mOnResizedCallback = onResizedCallback;
mOnActivityLayoutCallback = onActivityLayoutCallback;
mIsTablet = isTablet;
mInteractWithBackground = intentData.canInteractWithBackground();
mVersionCompat = PartialCustomTabVersionCompat.create(mActivity, this::updatePosition);
mDisplayHeight = mVersionCompat.getDisplayHeight();
mDisplayWidth = mVersionCompat.getDisplayWidth();
mFullscreenManager = fullscreenManager;
mFullscreenManager.addObserver(this);
mCachedHandleHeight =
mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_handle_height);
mOrientation = mActivity.getResources().getConfiguration().orientation;
mIsInMultiWindowMode = MultiWindowUtils.getInstance().isInMultiWindowMode(mActivity);
mHandleStrategyFactory = handleStrategyFactory;
// Initialize size info used for resize callback to skip the very first one that settles
// down to the initial height/width.
mHeight = MATCH_PARENT;
mWidth = MATCH_PARENT;
if (!sDeviceSpecLogged) {
logDeviceSpecForPcct(activity);
sDeviceSpecLogged = true;
}
}
static void logDeviceSpecForPcct(Context context) {
var pm = context.getPackageManager();
var am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
boolean pip = pm.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
boolean lowEnd = am.isLowRamDevice();
@DeviceSpec int spec;
if (lowEnd && !pip) {
spec = DeviceSpec.LOWEND_NOPIP;
} else if (lowEnd && pip) {
spec = DeviceSpec.LOWEND_PIP;
} else if (!lowEnd && !pip) {
spec = DeviceSpec.HIGHEND_NOPIP;
} else {
spec = DeviceSpec.HIGHEND_PIP;
}
RecordHistogram.recordEnumeratedHistogram("CustomTabs.DeviceSpec", spec, DeviceSpec.COUNT);
}
@Override
public void onPostInflationStartup() {
// Elevate the main web contents area as high as the handle bar to have the shadow
// effect look right.
View coordinatorLayout = getCoordinatorLayout();
coordinatorLayout.setElevation(getCustomTabsElevation());
mPositionUpdater.run();
// Set the window title so the type announcement is made, only when CCT is first launched.
if (!coordinatorLayout.isAttachedToWindow()) setWindowTitleForTouchExploration();
}
private void setWindowTitleForTouchExploration() {
View coordinatorLayout = getCoordinatorLayout();
var attachStateListener =
new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
Window window = mActivity.getWindow();
window.setTitle(mActivity.getResources().getString(getTypeStringId()));
coordinatorLayout.removeOnAttachStateChangeListener(this);
}
@Override
public void onViewDetachedFromWindow(View v) {}
};
coordinatorLayout.addOnAttachStateChangeListener(attachStateListener);
}
@Override
public void destroy() {
mFullscreenManager.removeObserver(this);
cleanupImeStateCallback();
if (mToolbarView != null) {
mToolbarView.removeContainerVisibilityChangeObserver(mVisibilityChangeObserver);
}
}
@Override
public void onToolbarInitialized(
View coordinatorView, CustomTabToolbar toolbar, @Px int toolbarCornerRadius) {
// The radius should not be bigger than the handle view default height of 16dp.
mToolbarCornerRadius = Math.min(toolbarCornerRadius, mCachedHandleHeight);
setToolbar(coordinatorView, toolbar);
roundCorners(toolbar, mToolbarCornerRadius);
}
public void setToolbar(View toolbarCoordinator, CustomTabToolbar toolbar) {
if (mToolbarView != null) {
mToolbarView.removeContainerVisibilityChangeObserver(mVisibilityChangeObserver);
}
mToolbarCoordinator = toolbarCoordinator;
mToolbarView = toolbar;
mToolbarColor = toolbar.getBackground().getColor();
mToolbarView.addContainerVisibilityChangeObserver(mVisibilityChangeObserver);
}
public void onShowSoftInput(Runnable softKeyboardRunnable) {
softKeyboardRunnable.run();
}
public void onConfigurationChanged(int orientation) {
boolean isInMultiWindow = MultiWindowUtils.getInstance().isInMultiWindowMode(mActivity);
int displayHeight = mVersionCompat.getDisplayHeight();
int displayWidth = mVersionCompat.getDisplayWidth();
if (isInMultiWindow != mIsInMultiWindowMode
|| orientation != mOrientation
|| displayHeight != mDisplayHeight
|| displayWidth != mDisplayWidth) {
mIsInMultiWindowMode = isInMultiWindow;
mOrientation = orientation;
mDisplayHeight = displayHeight;
mDisplayWidth = displayWidth;
if (isFullHeight()) {
// We should update CCT position before Window#FLAG_LAYOUT_NO_LIMITS is set,
// otherwise it is not possible to get the correct content height.
configureLayoutBeyondScreen(false);
// Clean up the state initiated by IME so the height can be restored when
// rotating back to non-full-height mode later.
cleanupImeStateCallback();
}
mPositionUpdater.run();
}
}
// FullscreenManager.Observer implementation
@Override
public void onEnterFullscreen(Tab tab, FullscreenOptions options) {
WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
attrs.height = MATCH_PARENT;
attrs.width = MATCH_PARENT;
attrs.y = 0;
attrs.x = 0;
mActivity.getWindow().setAttributes(attrs);
if (shouldDrawDividerLine()) resetCoordinatorLayoutInsets();
setTopMargins(0, 0);
maybeInvokeResizeCallback();
}
@Override
public void onExitFullscreen(Tab tab) {
// |mNavbarHeight| is zero now. Post the task instead.
new Handler()
.post(
() -> {
initializeSize();
if (shouldDrawDividerLine() && !isMaximized()) drawDividerLine();
if (!isMaximized()) updateShadowOffset();
maybeInvokeResizeCallback();
});
}
protected ViewGroup getCoordinatorLayout() {
// ContentFrame + CoordinatorLayout - CompositorViewHolder
// + NavigationBar
// + Spinner
// Not just CompositorViewHolder but also CoordinatorLayout is resized because many UI
// components such as BottomSheet, InfoBar, Snackbar are child views of CoordinatorLayout,
// which makes them appear correctly at the bottom.
return mActivity.findViewById(R.id.coordinator);
}
protected void maybeInvokeResizeCallback() {
WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
// onActivityLayout should be called before onResized and only when the PCCT is created
// or its size has changed.
if (mHeight != attrs.height || mWidth != attrs.width) {
invokeActivityLayoutCallback();
}
if (isFullHeight() || isFullscreen()) {
mOnResizedCallback.onResized(mDisplayHeight, mDisplayWidth);
mHeight = mDisplayHeight;
mWidth = mDisplayWidth;
} else {
if ((mHeight != attrs.height && mHeight > 0) || (mWidth != attrs.width && mWidth > 0)) {
mOnResizedCallback.onResized(attrs.height, attrs.width);
}
mHeight = attrs.height;
mWidth = attrs.width;
}
}
protected void invokeActivityLayoutCallback() {
@CustomTabsCallback.ActivityLayoutState int activityLayoutState = getActivityLayoutState();
// If we are in full screen then we manually need to set the values as we are using
// MATCH_PARENT which has the value -1.
int left = 0;
int top = 0;
int right = mDisplayWidth;
int bottom = mDisplayHeight;
if (activityLayoutState != ACTIVITY_LAYOUT_STATE_FULL_SCREEN) {
WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
left = attrs.x;
top = attrs.y;
right = left + attrs.width;
bottom = top + attrs.height;
}
mOnActivityLayoutCallback.onActivityLayout(left, top, right, bottom, activityLayoutState);
}
public abstract @PartialCustomTabType int getStrategyType();
public abstract @StringRes int getTypeStringId();
protected abstract @CustomTabsCallback.ActivityLayoutState int getActivityLayoutState();
protected abstract void updatePosition();
protected abstract int getHandleHeight();
protected abstract boolean isFullHeight();
protected abstract void cleanupImeStateCallback();
protected abstract void adjustCornerRadius(GradientDrawable d, int radius);
protected abstract void setTopMargins(int shadowOffset, int handleOffset);
protected abstract boolean shouldHaveNoShadowOffset();
protected abstract boolean isMaximized();
protected abstract void drawDividerLine();
protected abstract boolean shouldDrawDividerLine();
protected boolean canInteractWithBackground() {
return mInteractWithBackground;
}
protected void setCoordinatorLayoutHeight(int height) {
ViewGroup coordinator = getCoordinatorLayout();
ViewGroup.LayoutParams p = coordinator.getLayoutParams();
p.height = height;
coordinator.setLayoutParams(p);
}
protected void initializeHeight() {
Window window = mActivity.getWindow();
if (canInteractWithBackground()) {
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
window.setDimAmount(0.6f);
}
mNavbarHeight = mVersionCompat.getNavbarHeight();
mStatusbarHeight = mVersionCompat.getStatusbarHeight();
}
protected void initializeSize() {}
protected void updateDragBarVisibility(int dragHandlebarVisibility) {
View dragBar = mActivity.findViewById(R.id.drag_bar);
if (dragBar != null) dragBar.setVisibility(isFullHeight() ? View.GONE : View.VISIBLE);
View dragHandlebar = mActivity.findViewById(R.id.drag_handle);
if (dragHandlebar != null) {
dragHandlebar.setVisibility(dragHandlebarVisibility);
}
}
protected void updateShadowOffset() {
if (isFullHeight()
|| isFullscreen()
|| shouldHaveNoShadowOffset()
|| shouldDrawDividerLine()) {
mShadowOffset = 0;
} else {
mShadowOffset =
mActivity
.getResources()
.getDimensionPixelSize(R.dimen.custom_tabs_shadow_offset);
}
setTopMargins(mShadowOffset, getHandleHeight() + mShadowOffset);
ViewUtils.requestLayout(
mToolbarCoordinator, "PartialCustomTabBaseStrategy.updateShadowOffset");
}
protected void roundCorners(CustomTabToolbar toolbar, @Px int toolbarCornerRadius) {
// Inflate the handle View.
ViewStub handleViewStub = mActivity.findViewById(R.id.custom_tabs_handle_view_stub);
// If the handle view has already been inflated then the stub will be null. This can happen,
// for example, when we are transitioning from side-sheet to bottom-sheet and we need to
// apply the round corners logic again.
if (handleViewStub != null) {
handleViewStub.inflate();
}
getCoordinatorLayout().setElevation(getCustomTabsElevation());
View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
handleView.setElevation(getCustomTabsElevation());
updateShadowOffset();
GradientDrawable cctBackground = (GradientDrawable) handleView.getBackground();
adjustCornerRadius(cctBackground, toolbarCornerRadius);
handleView.setBackground(cctBackground);
// Inner frame |R.id.drag_bar| is used for setting background color to match that of
// the toolbar. Outer frame |R.id.custom_tabs_handle_view| is not suitable since it
// covers the entire client area for rendering outline shadow around the CCT.
View dragBar = handleView.findViewById(R.id.drag_bar);
GradientDrawable dragBarBackground = getDragBarBackground();
adjustCornerRadius(dragBarBackground, toolbarCornerRadius);
if (dragBar.getBackground() instanceof InsetDrawable) resetCoordinatorLayoutInsets();
if (shouldDrawDividerLine()) {
drawDividerLine();
} else {
dragBar.setBackground(dragBarBackground);
}
// Pass the drag bar portion to CustomTabToolbar for background color management.
toolbar.setHandleBackground(dragBarBackground);
// Having the transparent background is necessary for the shadow effect.
mActivity.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
}
protected void drawDividerLineBase(int leftInset, int topInset, int rightInset) {
View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
View dragBar = handleView.findViewById(R.id.drag_bar);
GradientDrawable cctBackground = (GradientDrawable) handleView.getBackground();
GradientDrawable dragBarBackground = getDragBarBackground();
int width =
mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_outline_width);
cctBackground.setStroke(width, SemanticColorUtils.getDividerLineBgColor(mActivity));
// We need an inset to make the outline shadow visible.
dragBar.setBackground(
new InsetDrawable(dragBarBackground, leftInset, topInset, rightInset, 0));
getCoordinatorLayout()
.setBackground(new InsetDrawable(cctBackground, leftInset, 0, rightInset, 0));
}
protected GradientDrawable getDragBarBackground() {
View dragBar = mActivity.findViewById(R.id.drag_bar);
// Check if the current dragBar background is the InsetDrawable used in conjunction with
// the divider line
if (dragBar.getBackground() instanceof InsetDrawable insetDrawable) {
return (GradientDrawable) insetDrawable.getDrawable();
} else {
return (GradientDrawable) dragBar.getBackground();
}
}
protected void resetCoordinatorLayoutInsets() {
ViewGroup coordinatorLayout = getCoordinatorLayout();
Drawable backgroundDrawable = coordinatorLayout.getBackground();
if (backgroundDrawable == null) return;
// Get the insets of the CoordinatorLayout
int insetLeft = coordinatorLayout.getPaddingLeft();
int insetTop = coordinatorLayout.getPaddingTop();
int insetRight = coordinatorLayout.getPaddingRight();
int insetBottom = coordinatorLayout.getPaddingBottom();
// Set the CoordinatorLayout to a new InsetDrawable with insets all offset back to 0.
InsetDrawable newDrawable =
new InsetDrawable(
backgroundDrawable, -insetLeft, -insetTop, -insetRight, -insetBottom);
coordinatorLayout.setBackground(newDrawable);
}
protected boolean isFullscreen() {
return mIsFullscreenForTesting != null
? mIsFullscreenForTesting.getAsBoolean()
: mFullscreenManager.getPersistentFullscreenMode();
}
protected void setupAnimator() {
mAnimator = new ValueAnimator();
mAnimator.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {}
@Override
public void onAnimationEnd(Animator animation) {
mPostAnimationRunnable.run();
}
});
int animTime = mActivity.getResources().getInteger(android.R.integer.config_mediumAnimTime);
mAnimator.setDuration(animTime);
mAnimator.setInterpolator(new AccelerateInterpolator());
}
protected void startAnimation(
int start,
int end,
ValueAnimator.AnimatorUpdateListener updateListener,
Runnable endRunnable) {
mAnimator.removeAllUpdateListeners();
mAnimator.addUpdateListener(updateListener);
mPostAnimationRunnable = endRunnable;
mAnimator.setIntValues(start, end);
mAnimator.start();
}
@Override
public boolean handleCloseAnimation(Runnable finishRunnable) {
// Can be entered twice - first from CustomTabToolbar (with a tap on close button)/
// HandleStrategy (swiping down), once again from RootUiCoordinator. Just run the passed
// runnable and return for the second invocation.
if (mFinishRunnable != null) {
if (finishRunnable != null) finishRunnable.run();
return false;
}
mFinishRunnable = finishRunnable;
return true;
}
protected void configureLayoutBeyondScreen(boolean enable) {
Window window = mActivity.getWindow();
if (enable) {
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
}
}
protected void setWindowX(int x) {
var attrs = mActivity.getWindow().getAttributes();
attrs.x = x;
mActivity.getWindow().setAttributes(attrs);
}
protected void setWindowY(int y) {
var attrs = mActivity.getWindow().getAttributes();
attrs.y = y;
mActivity.getWindow().setAttributes(attrs);
}
protected void setWindowWidth(int width) {
var attrs = mActivity.getWindow().getAttributes();
attrs.width = width;
mActivity.getWindow().setAttributes(attrs);
}
protected void onCloseAnimationEnd() {
assert mFinishRunnable != null;
mFinishRunnable.run();
mFinishRunnable = null;
}
protected int getCustomTabsElevation() {
return mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_elevation);
}
private void onToolbarContainerVisibilityChange(int visibility) {
// See https://crbug.com/1430948 for more context. The issue is that sometimes when
// exiting fullscreen, if we don't get a new layout, SurfaceFlinger doesn't recalculate
// transparent regions and this View (and children) are never shown. Theoretically this
// should also only ever need to be done the first time becoming visible after exiting
// fullscreen, but PCCTs do not currently allow scrolling off the toolbar, so it doesn't
// matter.
if (visibility == View.VISIBLE) {
ViewUtils.requestLayout(
mToolbarView,
"PartialCustomTabBaseStrategy.onToolbarContainerVisibilityChange");
}
}
void setMockViewForTesting(
ViewGroup coordinatorLayout, CustomTabToolbar toolbar, View toolbarCoordinator) {
mPositionUpdater = this::updatePosition;
mToolbarView = toolbar;
mToolbarCoordinator = toolbarCoordinator;
onPostInflationStartup();
}
void setFullscreenSupplierForTesting(BooleanSupplier fullscreen) {
mIsFullscreenForTesting = fullscreen;
}
int getTopMarginForTesting() {
var mlp = (ViewGroup.MarginLayoutParams) mToolbarCoordinator.getLayoutParams();
return mlp.topMargin;
}
int getShadowOffsetForTesting() {
return mShadowOffset;
}
static void resetDeviceSpecLoggedForTesting() {
sDeviceSpecLogged = false;
}
}