// Copyright 2016 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.history;
import android.app.Activity;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Build;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.google.android.material.tabs.TabLayout;
import org.chromium.base.IntentUtils;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.history.HistoryManagerToolbar.InfoHeaderPref;
import org.chromium.chrome.browser.incognito.IncognitoUtils;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.ui.messages.snackbar.Snackbar;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.browser_ui.widget.DateDividedAdapter.ItemViewType;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler;
import org.chromium.components.browser_ui.widget.selectable_list.SelectableListLayout;
import org.chromium.components.browser_ui.widget.selectable_list.SelectableListToolbar.SearchDelegate;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate.SelectionObserver;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.search_engines.TemplateUrl;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.ui.base.Clipboard;
import java.util.List;
/** Combines and manages the different UI components of browsing history. */
public class HistoryManager
implements OnMenuItemClickListener,
SelectionObserver<HistoryItem>,
SearchDelegate,
SnackbarController,
HistoryContentManager.Observer,
BackPressHandler {
private static final String METRICS_PREFIX = "Android.HistoryPage.";
static final String HISTORY_CLUSTERS_VISIBLE_PREF = "history_clusters.visible";
// Keep consistent with the UMA constants on the WebUI history page (history/constants.js).
private static final int UMA_MAX_BUCKET_VALUE = 1000;
private static final int UMA_MAX_SUBSET_BUCKET_VALUE = 100;
// TODO(msramek): The WebUI counterpart computes the bucket count by
// dividing by 10 until it gets under 100, reaching 10 for both
// UMA_MAX_BUCKET_VALUE and UMA_MAX_SUBSET_BUCKET_VALUE, and adds +1
// for overflow. How do we keep that in sync with this code?
private static final int HISTORY_TAB_INDEX = 0;
private static final int JOURNEYS_TAB_INDEX = 1;
private final Activity mActivity;
private final boolean mIsIncognito;
private final boolean mIsSeparateActivity;
private final boolean mLaunchedForApp;
private final HistoryUmaRecorder mUmaRecorder;
private final InfoHeaderPref mHeaderPref;
private final String mAppId;
private ViewGroup mRootView;
private ViewGroup mContentView;
@Nullable private final SelectableListLayout<HistoryItem> mSelectableListLayout;
private HistoryContentManager mContentManager;
private SelectionDelegate<HistoryItem> mSelectionDelegate;
private HistoryManagerToolbar mToolbar;
private TextView mEmptyView;
private final SnackbarManager mSnackbarManager;
private final ObservableSupplierImpl<Boolean> mShouldShowPrivacyDisclaimerSupplier =
new ObservableSupplierImpl<>();
private final ObservableSupplierImpl<Boolean> mShouldShowClearBrowsingDataSupplier =
new ObservableSupplierImpl<>();
private final ObservableSupplierImpl<Boolean> mBackPressStateSupplier =
new ObservableSupplierImpl<>();
private final PrefService mPrefService;
private final Profile mProfile;
private @Nullable TabLayout mHistoryTabToggle;
private @Nullable TabLayout mJourneysTabToggle;
private boolean mIsSearching;
public static boolean isAppSpecificHistoryEnabled() {
return ChromeFeatureList.sAppSpecificHistory.isEnabled()
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
}
/**
* Creates a new HistoryManager.
*
* @param activity The Activity associated with the HistoryManager.
* @param isSeparateActivity Whether the history UI will be shown in a separate activity than
* the main Chrome activity.
* @param snackbarManager The {@link SnackbarManager} used to display snackbars.
* @param profile The profile launching History.
* @param bottomSheetController Supplier of {@link BottomSheetController} to show app filter
* sheet in.
* @param tabSupplier Supplies the current tab, null if the history UI will be shown in a
* separate activity.
* @param historyProvider Provider of methods for querying and managing browsing history.
* @param umaRecorder Records UMA user action/histograms.
* @param clientPackageName Package name of the client the history UI is launched on top of.
* @param shouldShowClearData Whether the 'Clear browsing data' button should be shown.
* @param launchedForApp Whether history UI is launched for app-specific history.
* @param showAppFilter Whether history page will show app filter UI.
*/
@SuppressWarnings("unchecked") // mSelectableListLayout
public HistoryManager(
@NonNull Activity activity,
boolean isSeparateActivity,
@NonNull SnackbarManager snackbarManager,
@NonNull Profile profile,
@Nullable Supplier<BottomSheetController> bottomSheetController,
@Nullable Supplier<Tab> tabSupplier,
HistoryProvider historyProvider,
@NonNull HistoryUmaRecorder umaRecorder,
@Nullable String clientPackageName,
boolean shouldShowClearData,
boolean launchedForApp,
boolean showAppFilter) {
mActivity = activity;
mIsSeparateActivity = isSeparateActivity;
mSnackbarManager = snackbarManager;
assert profile != null;
mProfile = profile;
mIsIncognito = profile.isOffTheRecord();
mUmaRecorder = umaRecorder;
mLaunchedForApp = launchedForApp;
mAppId = clientPackageName;
mPrefService = UserPrefs.get(mProfile);
mBackPressStateSupplier.set(false);
// When launched for apps, info header always starts in hidden state.
mHeaderPref =
launchedForApp
? new AppHistoryInfoHeaderPref()
: new BrowserHistoryInfoHeaderPref();
mUmaRecorder.recordOpenHistory();
// If incognito placeholder is shown, we don't need to create History UI elements.
if (mIsIncognito) {
mSelectableListLayout = null;
mRootView = getIncognitoHistoryPlaceholderView();
return;
}
mRootView = new FrameLayout(mActivity);
// 1. Create selectable components.
mSelectableListLayout =
(SelectableListLayout<HistoryItem>)
LayoutInflater.from(activity).inflate(R.layout.history_main, null);
mSelectionDelegate = new SelectionDelegate<>();
mSelectionDelegate.addObserver(this);
// 2. Create HistoryContentManager and initialize recycler view.
boolean shouldShowInfoHeader = mHeaderPref.isVisible();
mContentManager =
new HistoryContentManager(
mActivity,
this,
isSeparateActivity,
profile,
shouldShowInfoHeader,
shouldShowClearData,
/* hostName= */ null,
mSelectionDelegate,
bottomSheetController,
tabSupplier,
() -> mToolbar.hideKeyboard(),
mUmaRecorder,
historyProvider,
clientPackageName,
launchedForApp,
showAppFilter);
mSelectableListLayout.initializeRecyclerView(
mContentManager.getAdapter(), mContentManager.getRecyclerView());
if (mContentManager.showAppFilter()) {
// Now the search mode can have a header. Let the layout ignore it to
// return the right item count.
mSelectableListLayout.ignoreItemTypeForEmptyState(ItemViewType.HEADER);
}
mShouldShowPrivacyDisclaimerSupplier.set(
shouldShowInfoHeader && mContentManager.isInfoHeaderAvailable());
mShouldShowClearBrowsingDataSupplier.set(mContentManager.getShouldShowClearData());
// 3. Initialize toolbar.
mToolbar =
(HistoryManagerToolbar)
mSelectableListLayout.initializeToolbar(
R.layout.history_toolbar,
mSelectionDelegate,
launchedForApp ? R.string.chrome_history : R.string.menu_history,
R.id.normal_menu_group,
R.id.selection_mode_menu_group,
this,
isSeparateActivity,
launchedForApp
? R.menu.app_specific_history_manager_menu
: R.menu.history_manager_menu,
launchedForApp);
mToolbar.setManager(this);
mToolbar.setMenuDelegate(
new HistoryManagerToolbar.HistoryManagerMenuDelegate() {
@Override
public boolean supportsDeletingHistory() {
return mPrefService.getBoolean(Pref.ALLOW_DELETING_BROWSER_HISTORY);
}
@Override
public boolean supportsIncognito() {
return IncognitoUtils.isIncognitoModeEnabled(profile);
}
});
mToolbar.initializeSearchView(this, R.string.history_manager_search, R.id.search_menu_id);
mToolbar.setInfoMenuItem(R.id.info_menu_id);
mToolbar.updateInfoMenuItem(shouldShowInfoButton(), shouldShowInfoHeaderIfAvailable());
// Make the toolbar focusable, so that focus transitions can move out from descendents of
// the toolbar to the neighboring delete button, and automatically to other items on the
// HistoryPage such as the list of HistoryItem(s).
mToolbar.setFocusable(true);
mToolbar.setNextFocusForwardId(R.id.clear_browsing_data_button);
mToolbar.setOnKeyListener(
(View view, int keyCode, KeyEvent event) -> {
if ((keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_DPAD_DOWN)
&& event.getAction() == KeyEvent.ACTION_UP) {
mToolbar.getMenu().performIdentifierAction(R.id.search_menu_id, 0);
return true;
}
return false;
});
// 4. Width constrain the SelectableListLayout.
mSelectableListLayout.configureWideDisplayStyle();
// 5. Initialize empty view.
initializeEmptyView();
// 6. Load items.
mContentManager.startLoadingItems();
setContentView(mSelectableListLayout);
mRootView.addView(mContentView);
mSelectableListLayout
.getHandleBackPressChangedSupplier()
.addObserver((x) -> onBackPressStateChanged());
onBackPressStateChanged(); // Initialize back press State.
mContentManager.maybeQueryApps();
}
private void initializeEmptyView() {
int imgResId =
mLaunchedForApp
? R.drawable.history_app_empty_state_illustration
: R.drawable.history_empty_state_illustration;
int subjResId =
mLaunchedForApp
? R.string.history_manager_app_specific_empty_state_title
: R.string.history_manager_empty_state;
Resources res = mActivity.getResources();
String descText =
mLaunchedForApp
? res.getString(
R.string.history_manager_app_specific_empty_state_description,
mContentManager.getAppInfoCache().get(mAppId).label)
: res.getString(
R.string.history_manager_empty_state_view_or_clear_page_visited);
mEmptyView = mSelectableListLayout.initializeEmptyStateView(imgResId, subjResId, descText);
}
/**
* @return Whether the history manager UI is displayed in a separate activity than the main
* Chrome activity.
*/
public boolean isDisplayedInSeparateActivity() {
return mIsSeparateActivity;
}
@Override
public boolean onMenuItemClick(MenuItem item) {
mToolbar.hideOverflowMenu();
if (item.getItemId() == R.id.close_menu_id && isDisplayedInSeparateActivity()) {
mActivity.finish();
return true;
} else if (item.getItemId() == R.id.selection_mode_open_in_new_tab) {
openItemsInNewTabs(mSelectionDelegate.getSelectedItemsAsList(), false);
return true;
} else if (item.getItemId() == R.id.selection_mode_copy_link) {
mUmaRecorder.recordCopyLink(mIsSearching);
Clipboard.getInstance()
.setText(mSelectionDelegate.getSelectedItemsAsList().get(0).getUrl().getSpec());
mSelectionDelegate.clearSelection();
Snackbar snackbar =
Snackbar.make(
mActivity.getString(R.string.copied),
this,
Snackbar.TYPE_NOTIFICATION,
Snackbar.UMA_HISTORY_LINK_COPIED);
mSnackbarManager.showSnackbar(snackbar);
return true;
} else if (item.getItemId() == R.id.selection_mode_open_in_incognito) {
openItemsInNewTabs(mSelectionDelegate.getSelectedItemsAsList(), true);
return true;
} else if (item.getItemId() == R.id.selection_mode_delete_menu_id) {
mUmaRecorder.recordRemoveSelected(mIsSearching);
int numItemsRemoved = 0;
HistoryItem lastItemRemoved = null;
for (HistoryItem historyItem : mSelectionDelegate.getSelectedItems()) {
mContentManager.markItemForRemoval(historyItem);
numItemsRemoved++;
lastItemRemoved = historyItem;
}
mContentManager.removeItems();
mSelectionDelegate.clearSelection();
if (numItemsRemoved == 1) {
assert lastItemRemoved != null;
mContentManager.announceItemRemoved(lastItemRemoved);
} else if (numItemsRemoved > 1) {
mContentManager
.getRecyclerView()
.announceForAccessibility(
mActivity.getString(
R.string.multiple_history_items_deleted, numItemsRemoved));
}
return true;
} else if (item.getItemId() == R.id.search_menu_id) {
mContentManager.maybeResetAppFilterChip();
mContentManager.getAdapter().onSearchStart();
mToolbar.showSearchView(true);
String searchEmptyString = getSearchEmptyString();
mSelectableListLayout.onStartSearch(
searchEmptyString,
R.string.history_manager_empty_state_view_or_open_more_history);
mUmaRecorder.recordSearchHistory();
mIsSearching = true;
return true;
} else if (item.getItemId() == R.id.info_menu_id) {
toggleInfoHeaderVisibility();
}
return false;
}
private void toggleInfoHeaderVisibility() {
boolean shouldShowInfoHeader =
!mContentManager.getShouldShowPrivacyDisclaimersIfAvailable();
mHeaderPref.setVisible(shouldShowInfoHeader);
mToolbar.updateInfoMenuItem(shouldShowInfoButton(), shouldShowInfoHeader);
mContentManager.updatePrivacyDisclaimers(shouldShowInfoHeader);
mShouldShowPrivacyDisclaimerSupplier.set(
shouldShowInfoHeader && mContentManager.isInfoHeaderAvailable());
}
private String getSearchEmptyString() {
if (mLaunchedForApp) {
return mActivity.getString(R.string.history_manager_app_specific_history_no_results);
}
String defaultSearchEngineName = null;
TemplateUrl dseTemplateUrl =
TemplateUrlServiceFactory.getForProfile(mProfile)
.getDefaultSearchEngineTemplateUrl();
if (dseTemplateUrl != null) defaultSearchEngineName = dseTemplateUrl.getShortName();
return defaultSearchEngineName == null
? mActivity.getString(R.string.history_manager_no_results_no_dse)
: mActivity.getString(R.string.history_manager_no_results, defaultSearchEngineName);
}
/**
* @return The view that shows the main browsing history UI.
*/
public ViewGroup getView() {
return mRootView;
}
/**
* @return The placeholder view to be shown instead of history UI in incognito mode.
*/
private ViewGroup getIncognitoHistoryPlaceholderView() {
ViewGroup placeholderView =
(ViewGroup)
LayoutInflater.from(mActivity)
.inflate(R.layout.incognito_history_placeholder, null);
ImageButton dismissButton =
placeholderView.findViewById(R.id.close_history_placeholder_button);
if (mIsSeparateActivity) {
dismissButton.setOnClickListener(v -> mActivity.finish());
} else {
dismissButton.setVisibility(View.GONE);
}
placeholderView.setFocusable(true);
placeholderView.setFocusableInTouchMode(true);
return placeholderView;
}
private void setContentView(ViewGroup contentView) {
mContentView = contentView;
onBackPressStateChanged();
}
/** Called when the activity/native page is destroyed. */
public void onDestroyed() {
if (mIsIncognito) {
// If Incognito placeholder is shown no need to call any destroy method.
return;
}
if (mSelectableListLayout != null) {
mSelectableListLayout.onDestroyed();
mContentManager.onDestroyed();
}
}
// BackPressHandler implementation.
@Override
public @BackPressResult int handleBackPress() {
return onBackPressed() ? BackPressResult.SUCCESS : BackPressResult.FAILURE;
}
@Override
public ObservableSupplier<Boolean> getHandleBackPressChangedSupplier() {
return mBackPressStateSupplier;
}
private void onBackPressStateChanged() {
mBackPressStateSupplier.set(
mSelectableListLayout.getHandleBackPressChangedSupplier().get());
}
@Override
public void onSearchTextChanged(String query) {
mContentManager.search(query);
}
@Override
public void onEndSearch() {
mContentManager.onEndSearch();
mSelectableListLayout.onEndSearch();
mIsSearching = false;
}
/** @return The SelectableListLayout that displays HistoryItems. */
public SelectableListLayout<HistoryItem> getSelectableListLayout() {
return mSelectableListLayout;
}
protected void finish() {
mActivity.finish();
}
private void openItemsInNewTabs(List<HistoryItem> items, boolean isIncognito) {
mUmaRecorder.recordOpenInTabs(mIsSearching, isIncognito);
mContentManager.openItemsInNewTab(items, isIncognito);
}
/**
* Called when the user presses the back key. This is only going to be called when the history
* UI is shown in a separate activity rather inside a tab.
*
* @return True if manager handles this event, false if it decides to ignore.
*/
private boolean onBackPressed() {
if (mIsIncognito || mSelectableListLayout == null) {
// If Incognito placeholder is shown, the back press should handled by HistoryActivity.
return false;
}
return mSelectableListLayout.onBackPressed();
}
/**
* @return True if info menu item should be shown on history toolbar, false otherwise.
*/
boolean shouldShowInfoButton() {
LinearLayoutManager layoutManager =
(LinearLayoutManager) mContentManager.getRecyclerView().getLayoutManager();
// Before the RecyclerView binds its items, LinearLayoutManager#firstVisibleItemPosition()
// returns {@link RecyclerView#NO_POSITION}. If #findVisibleItemPosition() returns
// NO_POSITION, the current adapter position should not prevent the info button from being
// displayed if all of the other criteria is met. See crbug.com/756249#c3.
boolean firstAdapterItemScrolledOff = layoutManager.findFirstVisibleItemPosition() > 0;
return !firstAdapterItemScrolledOff
&& mContentManager.isInfoHeaderAvailable()
&& mContentManager.getItemCount() > 0
&& !mToolbar.isSearching()
&& !mSelectionDelegate.isSelectionEnabled();
}
void showIPH() {
AppSpecificHistoryIPHController iphController =
new AppSpecificHistoryIPHController(mActivity, () -> mProfile);
iphController.maybeShowIPH();
}
/**
* @return True if the available privacy disclaimers should be shown. Note that this may return
* true even if there are currently no privacy disclaimers.
*/
boolean shouldShowInfoHeaderIfAvailable() {
return mContentManager.getShouldShowPrivacyDisclaimersIfAvailable();
}
void recordSelectionEstablished() {
mUmaRecorder.recordSelectionEstablished(mIsSearching);
}
@Override
public void onSelectionStateChange(List<HistoryItem> selectedItems) {
mContentManager.setSelectionActive(mSelectionDelegate.isSelectionEnabled());
}
@Override
public void onAction(Object actionData) {
// Handler for the link copied snackbar. Do nothing.
}
@Override
public void onDismissNoAction(Object actionData) {
// Handler for the link copied snackbar. Do nothing.
}
// HistoryContentManager.Observer
@Override
public void onScrolledCallback(boolean loadedMore) {
// Show info button if available if first visible position is close to info header;
// otherwise hide info button.
mToolbar.updateInfoMenuItem(shouldShowInfoButton(), shouldShowInfoHeaderIfAvailable());
if (loadedMore) {
mUmaRecorder.recordLoadMoreOnScroll(mIsSearching);
}
}
// HistoryContentManager.Observer
@Override
public void onItemClicked(HistoryItem item) {
mUmaRecorder.recordOpenItem(mIsSearching);
}
// HistoryContentManager.Observer
@Override
public void onItemRemoved(HistoryItem item) {
mUmaRecorder.recordRemoveItem(mIsSearching);
if (mSelectionDelegate.isItemSelected(item)) {
mSelectionDelegate.toggleSelectionForItem(item);
}
}
// HistoryContentManager.Observer
@Override
public void onClearBrowsingDataClicked() {
mUmaRecorder.recordClearBrowsingData(mIsIncognito);
// Opens the clear browsing data preference.
SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
settingsLauncher.launchSettingsActivity(
mActivity, SettingsLauncher.SettingsFragment.CLEAR_BROWSING_DATA_ADVANCED_PAGE);
}
// HistoryContentManager.Observer
@Override
public void onPrivacyDisclaimerHasChanged() {
mToolbar.updateInfoMenuItem(shouldShowInfoButton(), shouldShowInfoHeaderIfAvailable());
mShouldShowPrivacyDisclaimerSupplier.set(
mContentManager.getShouldShowPrivacyDisclaimersIfAvailable()
&& mContentManager.isInfoHeaderAvailable());
}
@Override
public void onOpenFullChromeHistoryClicked() {
Intent fullHistoryIntent = new Intent(Intent.ACTION_MAIN);
fullHistoryIntent.setClass(mActivity, ChromeLauncherActivity.class);
fullHistoryIntent.putExtra(IntentHandler.EXTRA_OPEN_HISTORY, true);
IntentUtils.addTrustedIntentExtras(fullHistoryIntent);
mActivity.startActivity(fullHistoryIntent);
mUmaRecorder.recordOpenFullHistory();
}
// HistoryContentManager.Observer
@Override
public void onUserAccountStateChanged() {
mToolbar.onSignInStateChange();
mShouldShowClearBrowsingDataSupplier.set(mContentManager.getShouldShowClearData());
}
// HistoryContentManager.Observer
@Override
public void onHistoryDeletedExternally() {}
TextView getEmptyViewForTests() {
return mEmptyView;
}
public HistoryContentManager getContentManagerForTests() {
return mContentManager;
}
SelectionDelegate<HistoryItem> getSelectionDelegateForTests() {
return mSelectionDelegate;
}
HistoryManagerToolbar getToolbarForTests() {
return mToolbar;
}
InfoHeaderPref getInfoHeaderPrefForTests() {
return mHeaderPref;
}
}