[go: up one dir, main page]

blob: 860b12a49868e4aca30f661288ab87deede2859d [file] [log] [blame]
// Copyright 2019 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.chrome.browser.tasks.tabgroup;
import android.content.Context;
import android.content.SharedPreferences;
import android.support.annotation.NonNull;
import org.chromium.base.ContextUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.ThreadUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.EmptyTabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabLaunchType;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.util.MathUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* An implementation of {@link TabModelFilter} that puts {@link Tab}s into a group
* structure.
*
* A group is a collection of {@link Tab}s that share a common ancestor {@link Tab}. This filter is
* also a {@link TabList} that contains the last shown {@link Tab} from every group.
*/
public class TabGroupModelFilter extends TabModelFilter {
private static final String PREFS_FILE = "tab_group_pref";
private static final String SESSIONS_COUNT_FOR_GROUP = "SessionsCountForGroup-";
private static SharedPreferences sPref;
/**
* An interface to be notified about changes to a {@link TabGroupModelFilter}.
*/
public interface Observer {
/**
* This method is called after a tab is moved to form a group or moved into an existed
* group.
* @param movedTab The {@link Tab} which has been moved. If a group is merged to a tab or
* another group, this is the last tab of the merged group.
* @param selectedTabIdInGroup The id of the selected {@link Tab} in group.
*/
void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup);
/**
* This method is called after a group is moved.
*
* @param movedTab The tab which has been moved. This is the last tab within the group.
* @param tabModelOldIndex The old index of the {@code movedTab} in the {@link TabModel}.
* @param tabModelNewIndex The new index of the {@code movedTab} in the {@link TabModel}.
*/
void didMoveTabGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex);
/**
* This method is called after a tab within a group is moved.
*
* @param movedTab The tab which has been moved.
* @param tabModelOldIndex The old index of the {@code movedTab} in the {@link TabModel}.
* @param tabModelNewIndex The new index of the {@code movedTab} in the {@link TabModel}.
*/
void didMoveWithinGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex);
}
/**
* This class is a representation of a group of tabs. It knows the last selected tab within the
* group.
*/
private class TabGroup {
private final static int INVALID_GROUP_ID = -1;
private final Set<Integer> mTabIds;
private int mLastShownTabId;
private int mGroupId;
TabGroup(int groupId) {
mTabIds = new LinkedHashSet<>();
mLastShownTabId = Tab.INVALID_TAB_ID;
mGroupId = groupId;
}
void addTab(int tabId) {
mTabIds.add(tabId);
if (mLastShownTabId == Tab.INVALID_TAB_ID) setLastShownTabId(tabId);
if (size() > 1) reorderGroup(mGroupId);
}
void removeTab(int tabId) {
assert mTabIds.contains(tabId);
if (mLastShownTabId == tabId) {
int nextIdToShow = nextTabIdToShow(tabId);
if (nextIdToShow != Tab.INVALID_TAB_ID) setLastShownTabId(nextIdToShow);
}
mTabIds.remove(tabId);
}
void moveToEndInGroup(int tabId) {
if (!mTabIds.contains(tabId)) return;
mTabIds.remove(tabId);
mTabIds.add(tabId);
}
boolean contains(int tabId) {
return mTabIds.contains(tabId);
}
int size() {
return mTabIds.size();
}
List<Integer> getTabIdList() {
return Collections.unmodifiableList(new ArrayList<>(mTabIds));
}
int getLastShownTabId() {
return mLastShownTabId;
}
void setLastShownTabId(int tabId) {
assert mTabIds.contains(tabId);
mLastShownTabId = tabId;
}
int nextTabIdToShow(int tabId) {
if (mTabIds.size() == 1 || !mTabIds.contains(tabId)) return Tab.INVALID_TAB_ID;
List<Integer> ids = getTabIdList();
int position = ids.indexOf(tabId);
if (position == 0) return ids.get(position + 1);
return ids.get(position - 1);
}
}
private ObserverList<Observer> mGroupFilterObserver = new ObserverList<>();
private Map<Integer, Integer> mGroupIdToGroupIndexMap = new HashMap<>();
private Map<Integer, TabGroup> mGroupIdToGroupMap = new HashMap<>();
private int mCurrentGroupIndex = TabList.INVALID_TAB_INDEX;
// The number of groups with at least 2 tabs.
private int mActualGroupCount;
private Tab mAbsentSelectedTab;
private boolean mShouldRecordUma = true;
public TabGroupModelFilter(TabModel tabModel) {
super(tabModel);
// Record the group count after all tabs are being restored. This only happen once per life
// cycle, therefore remove the observer after recording.
addObserver(new EmptyTabModelObserver() {
@Override
public void restoreCompleted() {
RecordHistogram.recordCountHistogram("TabGroups.UserGroupCount", mActualGroupCount);
Tab currentTab = TabModelUtils.getCurrentTab(getTabModel());
if (currentTab != null) recordSessionsCount(currentTab);
removeObserver(this);
}
});
}
/**
* This method adds a {@link Observer} to be notified on {@link TabGroupModelFilter} changes.
* @param observer The {@link Observer} to add.
*/
public void addTabGroupObserver(Observer observer) {
mGroupFilterObserver.addObserver(observer);
}
/**
* This method removes a {@link Observer}.
* @param observer The {@link Observer} to remove.
*/
public void removeTabGroupObserver(Observer observer) {
mGroupFilterObserver.removeObserver(observer);
}
/**
* @return Number of {@link TabGroup}s that has at least two tabs.
*/
public int getTabGroupCount() {
return mActualGroupCount;
}
/**
* This method records the number of sessions of the provided {@link Tab}, only if that
* {@link Tab} is in a group that has at least two tab, and it records as
* "TabGroups.SessionPerGroup".
* @param tab {@link Tab}
*/
public void recordSessionsCount(Tab tab) {
int groupId = tab.getRootId();
boolean isActualGroup = mGroupIdToGroupMap.get(groupId) != null
&& mGroupIdToGroupMap.get(groupId).size() > 1;
if (!isActualGroup) return;
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
int sessionsCount = updateAndGetSessionsCount(groupId);
RecordHistogram.recordCountHistogram("TabGroups.SessionsPerGroup", sessionsCount);
});
}
/**
* This method moves the TabGroup which contains the Tab with TabId {@code id} to
* {@code newIndex} in TabModel.
* @param id The id of the tab whose related tabs are being moved.
* @param newIndex The new index in TabModel that these tabs are being moved to.
*/
public void moveRelatedTabs(int id, int newIndex) {
List<Tab> tabs = getRelatedTabList(id);
TabModel tabModel = getTabModel();
newIndex = MathUtils.clamp(newIndex, 0, tabModel.getCount());
int curIndex = TabModelUtils.getTabIndexById(tabModel, tabs.get(0).getId());
if (curIndex == INVALID_TAB_INDEX || curIndex == newIndex) {
return;
}
int offset = 0;
for (Tab tab : tabs) {
if (tabModel.indexOf(tab) == -1) {
assert false : "Tried to close a tab from another model!";
continue;
}
tabModel.moveTab(tab.getId(), newIndex >= curIndex ? newIndex : newIndex + offset++);
}
}
/**
* This method merges the source group that contains the {@code sourceTabId} to the destination
* group that contains the {@code destinationTabId}. This method only operates if two groups are
* in the same {@code TabModel}.
*
* @param sourceTabId The id of the {@link Tab} to get the source group.
* @param destinationTabId The id of a {@link Tab} to get the destination group.
*/
public void mergeTabsToGroup(int sourceTabId, int destinationTabId) {
Tab sourceTab = TabModelUtils.getTabById(getTabModel(), sourceTabId);
Tab destinationTab = TabModelUtils.getTabById(getTabModel(), destinationTabId);
assert sourceTab != null && destinationTab != null
&& sourceTab.isIncognito()
== destinationTab.isIncognito()
: "Attempting to merge groups from different model";
int destinationGroupId = destinationTab.getRootId();
List<Tab> tabsToMerge = getRelatedTabList(sourceTabId);
int sourceTabIndexInTabModel =
TabModelUtils.getTabIndexById(getTabModel(), sourceTab.getId());
int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);
boolean isMovingBackward = sourceTabIndexInTabModel < destinationIndexInTabModel;
if (!needToUpdateTabModel(tabsToMerge, destinationIndexInTabModel)) {
for (int i = 0; i < tabsToMerge.size(); i++) {
Tab tab = tabsToMerge.get(i);
tab.setRootId(destinationGroupId);
}
resetFilterState();
Tab lastMergedTab = tabsToMerge.get(tabsToMerge.size() - 1);
TabGroup group = mGroupIdToGroupMap.get(lastMergedTab.getRootId());
for (Observer observer : mGroupFilterObserver) {
observer.didMergeTabToGroup(
tabsToMerge.get(tabsToMerge.size() - 1), group.getLastShownTabId());
}
} else {
for (int i = 0; i < tabsToMerge.size(); i++) {
Tab tab = tabsToMerge.get(i);
tab.setRootId(destinationGroupId);
getTabModel().moveTab(tab.getId(),
isMovingBackward ? destinationIndexInTabModel
: destinationIndexInTabModel++);
}
}
}
private int getTabModelDestinationIndex(Tab destinationTab) {
List<Integer> destinationGroupedTabIds =
mGroupIdToGroupMap.get(destinationTab.getRootId()).getTabIdList();
int destinationTabIndex = TabModelUtils.getTabIndexById(
getTabModel(), destinationGroupedTabIds.get(destinationGroupedTabIds.size() - 1));
return destinationTabIndex + 1;
}
private boolean needToUpdateTabModel(List<Tab> tabsToMerge, int destinationIndexInTabModel) {
assert tabsToMerge.size() > 0;
int firstTabIndexInTabModel =
TabModelUtils.getTabIndexById(getTabModel(), tabsToMerge.get(0).getId());
return firstTabIndexInTabModel != destinationIndexInTabModel;
}
// TODO(crbug.com/951608): follow up with sessions count histogram for TabGroups.
private int updateAndGetSessionsCount(int groupId) {
ThreadUtils.assertOnBackgroundThread();
String sessionsCountForGroupKey = SESSIONS_COUNT_FOR_GROUP + Integer.toString(groupId);
SharedPreferences prefs = getSharedPreferences();
int sessionsCount = prefs.getInt(sessionsCountForGroupKey, 0);
sessionsCount++;
prefs.edit().putInt(sessionsCountForGroupKey, sessionsCount).apply();
return sessionsCount;
}
private SharedPreferences getSharedPreferences() {
if (sPref == null) {
sPref = ContextUtils.getApplicationContext().getSharedPreferences(
PREFS_FILE, Context.MODE_PRIVATE);
}
return sPref;
}
// TabModelFilter implementation.
@NonNull
@Override
public List<Tab> getRelatedTabList(int id) {
// TODO(meiliang): In worst case, this method runs in O(n^2). This method needs to perform
// better, especially when we try to call it in a loop for all tabs.
Tab tab = TabModelUtils.getTabById(getTabModel(), id);
if (tab == null) return super.getRelatedTabList(id);
int groupId = tab.getRootId();
TabGroup group = mGroupIdToGroupMap.get(groupId);
if (group == null) return super.getRelatedTabList(TabModel.INVALID_TAB_INDEX);
return getRelatedTabList(group.getTabIdList());
}
private List<Tab> getRelatedTabList(List<Integer> ids) {
List<Tab> tabs = new ArrayList<>();
for (Integer id : ids) {
tabs.add(TabModelUtils.getTabById(getTabModel(), id));
}
return Collections.unmodifiableList(tabs);
}
@Override
protected void addTab(Tab tab) {
if (tab.isIncognito() != isIncognito()) {
throw new IllegalStateException("Attempting to open tab in the wrong model");
}
int groupId = tab.getRootId();
if (mGroupIdToGroupMap.containsKey(groupId)) {
if (mGroupIdToGroupMap.get(groupId).size() == 1) {
mActualGroupCount++;
if (mShouldRecordUma
&& tab.getLaunchType() == TabLaunchType.FROM_LONGPRESS_BACKGROUND) {
RecordUserAction.record("TabGroup.Created.OpenInNewTab");
}
}
mGroupIdToGroupMap.get(groupId).addTab(tab.getId());
} else {
TabGroup tabGroup = new TabGroup(tab.getRootId());
tabGroup.addTab(tab.getId());
mGroupIdToGroupMap.put(groupId, tabGroup);
mGroupIdToGroupIndexMap.put(groupId, mGroupIdToGroupIndexMap.size());
}
if (mAbsentSelectedTab != null) {
Tab absentSelectedTab = mAbsentSelectedTab;
mAbsentSelectedTab = null;
selectTab(absentSelectedTab);
}
}
@Override
protected void closeTab(Tab tab) {
int groupId = tab.getRootId();
if (tab.isIncognito() != isIncognito() || mGroupIdToGroupMap.get(groupId) == null
|| !mGroupIdToGroupMap.get(groupId).contains(tab.getId())) {
throw new IllegalStateException("Attempting to close tab in the wrong model");
}
TabGroup group = mGroupIdToGroupMap.get(groupId);
group.removeTab(tab.getId());
if (group.size() == 1) mActualGroupCount--;
if (group.size() == 0) {
updateGroupIdToGroupIndexMapAfterGroupClosed(groupId);
mGroupIdToGroupIndexMap.remove(groupId);
mGroupIdToGroupMap.remove(groupId);
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> removeGroupFromPref(groupId));
}
}
private void removeGroupFromPref(int groupId) {
ThreadUtils.assertOnBackgroundThread();
SharedPreferences prefs = getSharedPreferences();
String key = SESSIONS_COUNT_FOR_GROUP + Integer.toString(groupId);
if (prefs.contains(key)) {
prefs.edit().remove(key).apply();
}
}
private void updateGroupIdToGroupIndexMapAfterGroupClosed(int groupId) {
int indexToRemove = mGroupIdToGroupIndexMap.get(groupId);
Set<Integer> groupIdSet = mGroupIdToGroupIndexMap.keySet();
for (Integer groupIdKey : groupIdSet) {
int groupIndex = mGroupIdToGroupIndexMap.get(groupIdKey);
if (groupIndex > indexToRemove) {
mGroupIdToGroupIndexMap.put(groupIdKey, groupIndex - 1);
}
}
}
@Override
protected void selectTab(Tab tab) {
assert mAbsentSelectedTab == null;
int groupId = tab.getRootId();
if (mGroupIdToGroupMap.get(groupId) == null) {
mAbsentSelectedTab = tab;
} else {
mGroupIdToGroupMap.get(groupId).setLastShownTabId(tab.getId());
mCurrentGroupIndex = mGroupIdToGroupIndexMap.get(groupId);
}
}
@Override
protected void reorder() {
reorderGroup(TabGroup.INVALID_GROUP_ID);
TabModel tabModel = getTabModel();
selectTab(tabModel.getTabAt(tabModel.index()));
assert mGroupIdToGroupIndexMap.size() == mGroupIdToGroupMap.size();
}
private void reorderGroup(int groupId) {
boolean reorderAllGroups = groupId == TabGroup.INVALID_GROUP_ID;
if (reorderAllGroups) {
mGroupIdToGroupIndexMap.clear();
}
TabModel tabModel = getTabModel();
for (int i = 0; i < tabModel.getCount(); i++) {
Tab tab = tabModel.getTabAt(i);
if (reorderAllGroups) {
groupId = tab.getRootId();
if (!mGroupIdToGroupIndexMap.containsKey(groupId)) {
mGroupIdToGroupIndexMap.put(groupId, mGroupIdToGroupIndexMap.size());
}
}
mGroupIdToGroupMap.get(groupId).moveToEndInGroup(tab.getId());
}
}
@Override
protected void resetFilterStateInternal() {
mGroupIdToGroupIndexMap.clear();
mGroupIdToGroupMap.clear();
mActualGroupCount = 0;
}
@Override
protected void resetFilterState() {
mShouldRecordUma = false;
super.resetFilterState();
TabModel tabModel = getTabModel();
selectTab(tabModel.getTabAt(tabModel.index()));
mShouldRecordUma = true;
}
@Override
protected boolean shouldNotifyObserversOnSetIndex() {
return mAbsentSelectedTab == null;
}
@Override
public void didMoveTab(Tab tab, int newIndex, int curIndex) {
// Need to cache the flag before resetting the internal data map.
boolean isMergeTabToGroup = isMergeTabToGroup(tab);
int groupIdBeforeMove = getGroupIdBeforeMove(tab, isMergeTabToGroup);
assert groupIdBeforeMove != TabGroup.INVALID_GROUP_ID;
TabGroup groupBeforeMove = mGroupIdToGroupMap.get(groupIdBeforeMove);
if (isMergeTabToGroup) {
resetFilterState();
if (groupBeforeMove != null && groupBeforeMove.size() != 1) return;
TabGroup group = mGroupIdToGroupMap.get(tab.getRootId());
for (Observer observer : mGroupFilterObserver) {
observer.didMergeTabToGroup(tab, group.getLastShownTabId());
}
} else {
reorder();
if (isMoveWithinGroup(tab, curIndex, newIndex)) {
for (Observer observer : mGroupFilterObserver) {
observer.didMoveWithinGroup(tab, curIndex, newIndex);
}
} else {
if (!hasFinishedMovingGroup(tab, newIndex)) return;
for (Observer observer : mGroupFilterObserver) {
observer.didMoveTabGroup(tab, curIndex, newIndex);
}
}
}
super.didMoveTab(tab, newIndex, curIndex);
}
private boolean isMergeTabToGroup(Tab tab) {
TabGroup tabGroup = mGroupIdToGroupMap.get(tab.getRootId());
return !tabGroup.contains(tab.getId());
}
private int getGroupIdBeforeMove(Tab tabToMove, boolean isMoveToDifferentGroup) {
if (!isMoveToDifferentGroup) return tabToMove.getRootId();
Set<Integer> groupIdSet = mGroupIdToGroupMap.keySet();
for (Integer groupIdKey : groupIdSet) {
if (mGroupIdToGroupMap.get(groupIdKey).contains(tabToMove.getId())) {
return groupIdKey;
}
}
return TabGroup.INVALID_GROUP_ID;
}
private boolean isMoveWithinGroup(
Tab movedTab, int oldIndexInTabModel, int newIndexInTabModel) {
int startIndex = Math.min(oldIndexInTabModel, newIndexInTabModel);
int endIndex = Math.max(oldIndexInTabModel, newIndexInTabModel);
for (int i = startIndex; i <= endIndex; i++) {
if (getTabModel().getTabAt(i).getRootId() != movedTab.getRootId()) return false;
}
return true;
}
private boolean hasFinishedMovingGroup(Tab movedTab, int newIndexInTabModel) {
TabGroup tabGroup = mGroupIdToGroupMap.get(movedTab.getRootId());
int offsetIndex = newIndexInTabModel - tabGroup.size() + 1;
if (offsetIndex < 0) return false;
for (int i = newIndexInTabModel; i >= offsetIndex; i--) {
if (getTabModel().getTabAt(i).getRootId() != movedTab.getRootId()) return false;
}
return true;
}
// TabList implementation.
@Override
public boolean isIncognito() {
return getTabModel().isIncognito();
}
@Override
public int index() {
return mCurrentGroupIndex;
}
@Override
public int getCount() {
return mGroupIdToGroupMap.size();
}
@Override
public Tab getTabAt(int index) {
if (index < 0 || index >= getCount()) return null;
int groupId = Tab.INVALID_TAB_ID;
Set<Integer> groupIdSet = mGroupIdToGroupIndexMap.keySet();
for (Integer groupIdKey : groupIdSet) {
if (mGroupIdToGroupIndexMap.get(groupIdKey) == index) {
groupId = groupIdKey;
break;
}
}
if (groupId == Tab.INVALID_TAB_ID) return null;
return TabModelUtils.getTabById(
getTabModel(), mGroupIdToGroupMap.get(groupId).getLastShownTabId());
}
@Override
public int indexOf(Tab tab) {
if (tab == null || tab.isIncognito() != isIncognito()) return TabList.INVALID_TAB_INDEX;
return mGroupIdToGroupIndexMap.get(tab.getRootId());
}
@Override
public boolean isClosurePending(int tabId) {
return getTabModel().isClosurePending(tabId);
}
}