// Copyright 2013 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.appmenu;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.TypedArray;
import android.graphics.Point;
import android.graphics.Rect;
import android.view.ContextThemeWrapper;
import android.view.Display;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.ConfigurationChangedObserver;
import org.chromium.chrome.browser.lifecycle.StartStopWithNativeObserver;
import org.chromium.chrome.browser.ui.appmenu.internal.R;
import org.chromium.components.browser_ui.widget.textbubble.TextBubble;
import org.chromium.ui.display.DisplayAndroidManager;
import org.chromium.ui.modelutil.LayoutViewBuilder;
import org.chromium.ui.modelutil.ListObservable;
import org.chromium.ui.modelutil.ListObservable.ListObserver;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.ModelListAdapter;
import org.chromium.ui.modelutil.PropertyModel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Object responsible for handling the creation, showing, hiding of the AppMenu and notifying the
* AppMenuObservers about these actions.
*/
class AppMenuHandlerImpl
implements AppMenuHandler, StartStopWithNativeObserver, ConfigurationChangedObserver {
private AppMenu mAppMenu;
private AppMenuDragHelper mAppMenuDragHelper;
private final List<AppMenuBlocker> mBlockers;
private final List<AppMenuObserver> mObservers;
private final View mHardwareButtonMenuAnchor;
private final Context mContext;
private final AppMenuPropertiesDelegate mDelegate;
private final AppMenuDelegate mAppMenuDelegate;
private final View mDecorView;
private final ActivityLifecycleDispatcher mActivityLifecycleDispatcher;
private final Supplier<Rect> mAppRect;
private ModelList mModelList;
private ListObserver<Void> mListObserver;
private Callback<Integer> mTestOptionsItemSelectedListener;
/**
* The resource id of the menu item to highlight when the menu next opens. A value of {@code
* null} means no item will be highlighted. This value will be cleared after the menu is opened.
*/
private Integer mHighlightMenuId;
/**
* Constructs an AppMenuHandlerImpl object.
* @param context The activity context.
* @param delegate Delegate used to check the desired AppMenu properties on show.
* @param appMenuDelegate The AppMenuDelegate to handle menu item selection.
* @param menuResourceId Resource Id that should be used as the source for the menu items.
* It is assumed to have back_menu_id, forward_menu_id, bookmark_this_page_id.
* @param decorView The decor {@link View}, e.g. from Window#getDecorView(), for the containing
* activity.
* @param activityLifecycleDispatcher The {@link ActivityLifecycleDispatcher} for the containing
* activity.
* @param hardwareButtonAnchorView The {@link View} used as an anchor for the menu when it is
* displayed using a hardware button.
* @param appRect Supplier of the app area in Window that the menu should fit in.
*/
public AppMenuHandlerImpl(
Context context,
AppMenuPropertiesDelegate delegate,
AppMenuDelegate appMenuDelegate,
View decorView,
ActivityLifecycleDispatcher activityLifecycleDispatcher,
View hardwareButtonAnchorView,
Supplier<Rect> appRect) {
mContext = context;
mAppMenuDelegate = appMenuDelegate;
mDelegate = delegate;
mDecorView = decorView;
mBlockers = new ArrayList<>();
mObservers = new ArrayList<>();
mHardwareButtonMenuAnchor = hardwareButtonAnchorView;
mAppRect = appRect;
mActivityLifecycleDispatcher = activityLifecycleDispatcher;
mActivityLifecycleDispatcher.register(this);
assert mHardwareButtonMenuAnchor != null
: "Using AppMenu requires to have menu_anchor_stub view";
mListObserver =
new ListObserver<Void>() {
@Override
public void onItemRangeInserted(ListObservable source, int index, int count) {
assert mModelList != null && mAppMenu != null;
updateModelForHighlightAndClick(
mModelList,
mHighlightMenuId,
mAppMenu,
/* startIndex= */ index,
/* withAssertions= */ false);
}
@Override
public void onItemRangeRemoved(ListObservable source, int index, int count) {
assert mModelList != null;
updateModelForHighlightAndClick(
mModelList,
mHighlightMenuId,
mAppMenu,
/* startIndex= */ index,
/* withAssertions= */ false);
}
};
}
/** Called when the containing activity is being destroyed. */
void destroy() {
// Prevent the menu window from leaking.
hideAppMenu();
mActivityLifecycleDispatcher.unregister(this);
}
@Override
public void menuItemContentChanged(int menuRowId) {
if (mAppMenu != null) mAppMenu.menuItemContentChanged(menuRowId);
}
@Override
public void clearMenuHighlight() {
setMenuHighlight(null);
}
@Override
public void setMenuHighlight(Integer highlightItemId) {
boolean highlighting = highlightItemId != null;
setMenuHighlight(highlightItemId, highlighting);
}
@Override
public void setMenuHighlight(Integer highlightItemId, boolean shouldHighlightMenuButton) {
if (mHighlightMenuId == null && highlightItemId == null) return;
if (mHighlightMenuId != null && mHighlightMenuId.equals(highlightItemId)) return;
mHighlightMenuId = highlightItemId;
for (AppMenuObserver observer : mObservers) {
observer.onMenuHighlightChanged(shouldHighlightMenuButton);
}
}
/**
* Show the app menu.
*
* @param anchorView Anchor view (usually a menu button) to be used for the popup, if null is
* passed then hardware menu button anchor will be used.
* @param startDragging Whether dragging is started. For example, if the app menu is showed by
* tapping on a button, this should be false. If it is showed by start dragging down on the
* menu button, this should be true. Note that if anchorView is null, this must be false
* since we no longer support hardware menu button dragging.
* @return True, if the menu is shown, false, if menu is not shown, example reasons: the menu is
* not yet available to be shown, or the menu is already showing.
*/
// TODO(crbug.com/40479664): Fix this properly.
@SuppressLint("ResourceType")
boolean showAppMenu(View anchorView, boolean startDragging) {
if (!shouldShowAppMenu() || isAppMenuShowing()) return false;
TextBubble.dismissBubbles();
boolean isByPermanentButton = false;
Display display = DisplayAndroidManager.getDefaultDisplayForContext(mContext);
int rotation = display.getRotation();
if (anchorView == null) {
// This fixes the bug where the bottom of the menu starts at the top of
// the keyboard, instead of overlapping the keyboard as it should.
int displayHeight = mContext.getResources().getDisplayMetrics().heightPixels;
Rect rect = new Rect();
mDecorView.getWindowVisibleDisplayFrame(rect);
int statusBarHeight = rect.top;
mHardwareButtonMenuAnchor.setY((displayHeight - statusBarHeight));
anchorView = mHardwareButtonMenuAnchor;
isByPermanentButton = true;
}
// If the anchor view used to show the popup or the activity's decor view is not attached
// to window, we don't show the app menu because the window manager might have revoked
// the window token for this activity. See https://crbug.com/1105831.
if (!mDecorView.isAttachedToWindow()
|| !anchorView.isAttachedToWindow()
|| !anchorView.getRootView().isAttachedToWindow()) {
return false;
}
assert !(isByPermanentButton && startDragging);
List<CustomViewBinder> customViewBinders = mDelegate.getCustomViewBinders();
Map<CustomViewBinder, Integer> customViewTypeOffsetMap =
populateCustomViewBinderOffsetMap(customViewBinders, AppMenuItemType.NUM_ENTRIES);
mModelList =
mDelegate.getMenuItems(
((id) -> {
return getCustomItemViewType(
id, customViewBinders, customViewTypeOffsetMap);
}),
this);
mModelList.addObserver(mListObserver);
ContextThemeWrapper wrapper =
new ContextThemeWrapper(mContext, R.style.OverflowMenuThemeOverlay);
if (mAppMenu == null) {
TypedArray a =
wrapper.obtainStyledAttributes(
new int[] {android.R.attr.listPreferredItemHeightSmall});
int itemRowHeight = a.getDimensionPixelSize(0, 0);
a.recycle();
mAppMenu = new AppMenu(itemRowHeight, this, mContext.getResources());
mAppMenuDragHelper = new AppMenuDragHelper(mContext, mAppMenu, itemRowHeight);
}
setupModelForHighlightAndClick(mModelList, mHighlightMenuId, mAppMenu);
ModelListAdapter adapter = new ModelListAdapter(mModelList);
mAppMenu.updateMenu(mModelList, adapter);
registerViewBinders(
customViewBinders,
customViewTypeOffsetMap,
adapter,
mDelegate.shouldShowIconBeforeItem());
Rect appRect = mAppRect.get();
// Use full size of window for abnormal appRect.
if (appRect.left < 0 && appRect.top < 0) {
appRect.left = 0;
appRect.top = 0;
appRect.right = mDecorView.getWidth();
appRect.bottom = mDecorView.getHeight();
}
Point pt = new Point();
display.getSize(pt);
int footerResourceId = 0;
if (mDelegate.shouldShowFooter(appRect.height())) {
footerResourceId = mDelegate.getFooterResourceId();
}
int headerResourceId = 0;
if (mDelegate.shouldShowHeader(appRect.height())) {
headerResourceId = mDelegate.getHeaderResourceId();
}
mAppMenu.show(
wrapper,
anchorView,
isByPermanentButton,
rotation,
appRect,
footerResourceId,
headerResourceId,
mDelegate.getGroupDividerId(),
mHighlightMenuId,
customViewBinders,
mDelegate.isMenuIconAtStart());
mAppMenuDragHelper.onShow(startDragging);
clearMenuHighlight();
RecordUserAction.record("MobileMenuShow");
mDelegate.onMenuShown();
return true;
}
void appMenuDismissed() {
mAppMenuDragHelper.finishDragging();
mDelegate.onMenuDismissed();
}
@Override
public boolean isAppMenuShowing() {
return mAppMenu != null && mAppMenu.isShowing();
}
/**
* @return The App Menu that the menu handler is interacting with.
*/
public AppMenu getAppMenu() {
return mAppMenu;
}
AppMenuDragHelper getAppMenuDragHelper() {
return mAppMenuDragHelper;
}
@Override
public void hideAppMenu() {
if (mAppMenu != null && mAppMenu.isShowing()) {
mAppMenu.dismiss();
if (mModelList != null) {
mModelList.removeObserver(mListObserver);
}
}
}
@Override
public AppMenuButtonHelper createAppMenuButtonHelper() {
return new AppMenuButtonHelperImpl(this);
}
@Override
public void invalidateAppMenu() {
if (mAppMenu != null) mAppMenu.invalidate();
}
@Override
public void addObserver(AppMenuObserver observer) {
mObservers.add(observer);
}
@Override
public void removeObserver(AppMenuObserver observer) {
mObservers.remove(observer);
}
// StartStopWithNativeObserver implementation
@Override
public void onStartWithNative() {}
@Override
public void onStopWithNative() {
hideAppMenu();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
hideAppMenu();
}
@VisibleForTesting
void onOptionsItemSelected(int itemId) {
if (mTestOptionsItemSelectedListener != null) {
mTestOptionsItemSelectedListener.onResult(itemId);
return;
}
mAppMenuDelegate.onOptionsItemSelected(itemId, mDelegate.getBundleForMenuItem(itemId));
}
/**
* Called by AppMenu to report that the App Menu visibility has changed.
* @param isVisible Whether the App Menu is showing.
*/
void onMenuVisibilityChanged(boolean isVisible) {
for (int i = 0; i < mObservers.size(); ++i) {
mObservers.get(i).onMenuVisibilityChanged(isVisible);
}
}
/**
* A notification that the header view has been inflated.
* @param view The inflated view.
*/
void onHeaderViewInflated(View view) {
if (mDelegate != null) mDelegate.onHeaderViewInflated(this, view);
}
/**
* A notification that the footer view has been inflated.
* @param view The inflated view.
*/
void onFooterViewInflated(View view) {
if (mDelegate != null) mDelegate.onFooterViewInflated(this, view);
}
/**
* Registers an {@link AppMenuBlocker} used to help determine whether the app menu can be shown.
* @param blocker An {@link AppMenuBlocker} to check before attempting to show the app menu.
*/
void registerAppMenuBlocker(AppMenuBlocker blocker) {
if (!mBlockers.contains(blocker)) mBlockers.add(blocker);
}
/**
* @param blocker The {@link AppMenuBlocker} to unregister.
*/
void unregisterAppMenuBlocker(AppMenuBlocker blocker) {
mBlockers.remove(blocker);
}
boolean shouldShowAppMenu() {
for (int i = 0; i < mBlockers.size(); i++) {
if (!mBlockers.get(i).canShowAppMenu()) return false;
}
return true;
}
void overrideOnOptionItemSelectedListenerForTests(
Callback<Integer> onOptionsItemSelectedListener) {
mTestOptionsItemSelectedListener = onOptionsItemSelectedListener;
}
AppMenuPropertiesDelegate getDelegateForTests() {
return mDelegate;
}
private void registerViewBinders(
@Nullable List<CustomViewBinder> customViewBinders,
Map<CustomViewBinder, Integer> customViewTypeOffsetMap,
ModelListAdapter adapter,
boolean iconBeforeItem) {
int standardItemResId = R.layout.menu_item;
if (iconBeforeItem) {
standardItemResId = R.layout.menu_item_start_with_icon;
}
adapter.registerType(
AppMenuItemType.STANDARD,
new LayoutViewBuilder(standardItemResId),
AppMenuItemViewBinder::bindStandardItem);
adapter.registerType(
AppMenuItemType.TITLE_BUTTON,
new LayoutViewBuilder(R.layout.title_button_menu_item),
AppMenuItemViewBinder::bindTitleButtonItem);
adapter.registerType(
AppMenuItemType.THREE_BUTTON_ROW,
new LayoutViewBuilder(R.layout.icon_row_menu_item),
AppMenuItemViewBinder::bindIconRowItem);
adapter.registerType(
AppMenuItemType.FOUR_BUTTON_ROW,
new LayoutViewBuilder(R.layout.icon_row_menu_item),
AppMenuItemViewBinder::bindIconRowItem);
adapter.registerType(
AppMenuItemType.FIVE_BUTTON_ROW,
new LayoutViewBuilder(R.layout.icon_row_menu_item),
AppMenuItemViewBinder::bindIconRowItem);
if (customViewBinders == null) return;
for (int i = 0; i < customViewBinders.size(); i++) {
CustomViewBinder binder = customViewBinders.get(i);
if (customViewTypeOffsetMap.get(binder) == null) {
continue;
}
for (int j = 0; j < binder.getViewTypeCount(); j++) {
adapter.registerType(
customViewTypeOffsetMap.get(binder) + j,
new LayoutViewBuilder(binder.getLayoutId(j)),
binder);
}
}
}
void setupModelForHighlightAndClick(
ModelList modelList, Integer highlightedId, AppMenuClickHandler appMenuClickHandler) {
updateModelForHighlightAndClick(
modelList,
highlightedId,
appMenuClickHandler,
/* startIndex= */ 0,
/* withAssertions= */ true);
}
private void updateModelForHighlightAndClick(
ModelList modelList,
Integer highlightedId,
AppMenuClickHandler appMenuClickHandler,
int startIndex,
boolean withAssertions) {
if (modelList == null) {
return;
}
for (int i = startIndex; i < modelList.size(); i++) {
PropertyModel model = modelList.get(i).model;
if (withAssertions) {
// Not like other keys which is set by AppMenuPropertiesDelegateImpl, CLICK_HANDLER
// and HIGHLIGHTED should not be set yet.
assert model.get(AppMenuItemProperties.CLICK_HANDLER) == null;
assert !model.get(AppMenuItemProperties.HIGHLIGHTED);
}
model.set(AppMenuItemProperties.CLICK_HANDLER, appMenuClickHandler);
model.set(AppMenuItemProperties.POSITION, i);
if (highlightedId != null) {
model.set(
AppMenuItemProperties.HIGHLIGHTED,
model.get(AppMenuItemProperties.MENU_ITEM_ID) == highlightedId);
if (model.get(AppMenuItemProperties.SUBMENU) != null) {
ModelList subList = model.get(AppMenuItemProperties.SUBMENU);
for (int j = 0; j < subList.size(); j++) {
PropertyModel subModel = subList.get(j).model;
subModel.set(AppMenuItemProperties.CLICK_HANDLER, appMenuClickHandler);
subModel.set(
AppMenuItemProperties.HIGHLIGHTED,
subModel.get(AppMenuItemProperties.MENU_ITEM_ID) == highlightedId);
}
}
}
}
}
private Map<CustomViewBinder, Integer> populateCustomViewBinderOffsetMap(
@Nullable List<CustomViewBinder> customViewBinders, int startingOffset) {
Map<CustomViewBinder, Integer> customViewTypeOffsetMap = new HashMap<>();
if (customViewBinders == null) return customViewTypeOffsetMap;
int currentOffset = startingOffset;
for (int i = 0; i < customViewBinders.size(); i++) {
CustomViewBinder binder = customViewBinders.get(i);
customViewTypeOffsetMap.put(binder, currentOffset);
currentOffset += binder.getViewTypeCount();
}
return customViewTypeOffsetMap;
}
private int getCustomItemViewType(
int id,
List<CustomViewBinder> customViewBinders,
Map<CustomViewBinder, Integer> customViewTypeOffsetMap) {
if (customViewBinders == null || customViewTypeOffsetMap == null) {
return CustomViewBinder.NOT_HANDLED;
}
for (int i = 0; i < customViewBinders.size(); i++) {
CustomViewBinder binder = customViewBinders.get(i);
int binderViewType = binder.getItemViewType(id);
if (binderViewType != CustomViewBinder.NOT_HANDLED) {
return binderViewType + customViewTypeOffsetMap.get(binder);
}
}
return CustomViewBinder.NOT_HANDLED;
}
/** @param reporter A means of reporting an exception without crashing. */
static void setExceptionReporter(Callback<Throwable> reporter) {
AppMenu.setExceptionReporter(reporter);
}
@Nullable
ModelList getModelListForTesting() {
return mModelList;
}
}