1 // Copyright 2019 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.tasks.tab_groups;
6 
7 import android.content.Context;
8 import android.content.SharedPreferences;
9 
10 import androidx.annotation.NonNull;
11 import androidx.annotation.VisibleForTesting;
12 
13 import org.chromium.base.ContextUtils;
14 import org.chromium.base.MathUtils;
15 import org.chromium.base.ObserverList;
16 import org.chromium.base.ThreadUtils;
17 import org.chromium.base.metrics.RecordHistogram;
18 import org.chromium.base.metrics.RecordUserAction;
19 import org.chromium.base.task.AsyncTask;
20 import org.chromium.chrome.browser.tab.Tab;
21 import org.chromium.chrome.browser.tab.TabLaunchType;
22 import org.chromium.chrome.browser.tab.state.CriticalPersistedTabData;
23 import org.chromium.chrome.browser.tabmodel.TabList;
24 import org.chromium.chrome.browser.tabmodel.TabModel;
25 import org.chromium.chrome.browser.tabmodel.TabModelFilter;
26 import org.chromium.chrome.browser.tabmodel.TabModelUtils;
27 
28 import java.util.ArrayList;
29 import java.util.Collections;
30 import java.util.HashMap;
31 import java.util.LinkedHashSet;
32 import java.util.List;
33 import java.util.Map;
34 import java.util.Set;
35 
36 /**
37  * An implementation of {@link TabModelFilter} that puts {@link Tab}s into a group
38  * structure.
39  *
40  * A group is a collection of {@link Tab}s that share a common ancestor {@link Tab}. This filter is
41  * also a {@link TabList} that contains the last shown {@link Tab} from every group.
42  */
43 public class TabGroupModelFilter extends TabModelFilter {
44     private static final String PREFS_FILE = "tab_group_pref";
45     private static final String SESSIONS_COUNT_FOR_GROUP = "SessionsCountForGroup-";
46     private static SharedPreferences sPref;
47 
48     /**
49      * An interface to be notified about changes to a {@link TabGroupModelFilter}.
50      */
51     public interface Observer {
52         /**
53          * This method is called before a tab is moved to form a group or moved into an existed
54          * group.
55          * @param movedTab The {@link Tab} which will be moved. If a group will be merged to a tab
56          *                 or another group, this is the last tab of the merged group.
57          * @param newRootId  The new root id of the group after merge.
58          */
willMergeTabToGroup(Tab movedTab, int newRootId)59         void willMergeTabToGroup(Tab movedTab, int newRootId);
60 
61         /**
62          * This method is called before a tab within a group is moved out of the group.
63          *
64          * @param movedTab   The tab which will be moved.
65          * @param newRootId  The new root id of the group from which {@code movedTab} is moved out.
66          */
willMoveTabOutOfGroup(Tab movedTab, int newRootId)67         void willMoveTabOutOfGroup(Tab movedTab, int newRootId);
68 
69         /**
70          * This method is called after a tab is moved to form a group or moved into an existed
71          * group.
72          * @param movedTab The {@link Tab} which has been moved. If a group is merged to a tab or
73          *                 another group, this is the last tab of the merged group.
74          * @param selectedTabIdInGroup The id of the selected {@link Tab} in group.
75          */
didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup)76         void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup);
77 
78         /**
79          * This method is called after a group is moved.
80          *
81          * @param movedTab The tab which has been moved. This is the last tab within the group.
82          * @param tabModelOldIndex The old index of the {@code movedTab} in the {@link TabModel}.
83          * @param tabModelNewIndex The new index of the {@code movedTab} in the {@link TabModel}.
84          */
didMoveTabGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex)85         void didMoveTabGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex);
86 
87         /**
88          * This method is called after a tab within a group is moved.
89          *
90          * @param movedTab The tab which has been moved.
91          * @param tabModelOldIndex The old index of the {@code movedTab} in the {@link TabModel}.
92          * @param tabModelNewIndex The new index of the {@code movedTab} in the {@link TabModel}.
93          */
didMoveWithinGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex)94         void didMoveWithinGroup(Tab movedTab, int tabModelOldIndex, int tabModelNewIndex);
95 
96         /**
97          * This method is called after a tab within a group is moved out of the group.
98          *
99          * @param movedTab The tab which has been moved.
100          * @param prevFilterIndex The index in {@link TabGroupModelFilter} of the group where {@code
101          *         moveTab} is in  before ungrouping.
102          */
didMoveTabOutOfGroup(Tab movedTab, int prevFilterIndex)103         void didMoveTabOutOfGroup(Tab movedTab, int prevFilterIndex);
104 
105         /**
106          * This method is called after a group is created manually by user. Either using the
107          * TabSelectionEditor (Group tab menu item) or using drag and drop.
108          * @param tabs The list of modified {@link Tab}s.
109          * @param tabOriginalIndex The original tab index for each modified tab.
110          * @param isSameGroup Whether the given list is in a group already.
111          */
didCreateGroup( List<Tab> tabs, List<Integer> tabOriginalIndex, boolean isSameGroup)112         void didCreateGroup(
113                 List<Tab> tabs, List<Integer> tabOriginalIndex, boolean isSameGroup);
114     }
115 
116     /**
117      * This class is a representation of a group of tabs. It knows the last selected tab within the
118      * group.
119      */
120     private class TabGroup {
121         private static final int INVALID_GROUP_ID = -1;
122         private final Set<Integer> mTabIds;
123         private int mLastShownTabId;
124         private int mGroupId;
125 
TabGroup(int groupId)126         TabGroup(int groupId) {
127             mTabIds = new LinkedHashSet<>();
128             mLastShownTabId = Tab.INVALID_TAB_ID;
129             mGroupId = groupId;
130         }
131 
addTab(int tabId)132         void addTab(int tabId) {
133             mTabIds.add(tabId);
134             if (mLastShownTabId == Tab.INVALID_TAB_ID) setLastShownTabId(tabId);
135             if (size() > 1) reorderGroup(mGroupId);
136         }
137 
removeTab(int tabId)138         void removeTab(int tabId) {
139             assert mTabIds.contains(tabId);
140             if (mLastShownTabId == tabId) {
141                 int nextIdToShow = nextTabIdToShow(tabId);
142                 if (nextIdToShow != Tab.INVALID_TAB_ID) setLastShownTabId(nextIdToShow);
143             }
144             mTabIds.remove(tabId);
145         }
146 
moveToEndInGroup(int tabId)147         void moveToEndInGroup(int tabId) {
148             if (!mTabIds.contains(tabId)) return;
149             mTabIds.remove(tabId);
150             mTabIds.add(tabId);
151         }
152 
contains(int tabId)153         boolean contains(int tabId) {
154             return mTabIds.contains(tabId);
155         }
156 
size()157         int size() {
158             return mTabIds.size();
159         }
160 
getTabIdList()161         List<Integer> getTabIdList() {
162             return Collections.unmodifiableList(new ArrayList<>(mTabIds));
163         }
164 
getLastShownTabId()165         int getLastShownTabId() {
166             return mLastShownTabId;
167         }
168 
setLastShownTabId(int tabId)169         void setLastShownTabId(int tabId) {
170             assert mTabIds.contains(tabId);
171             mLastShownTabId = tabId;
172         }
173 
nextTabIdToShow(int tabId)174         int nextTabIdToShow(int tabId) {
175             if (mTabIds.size() == 1 || !mTabIds.contains(tabId)) return Tab.INVALID_TAB_ID;
176             List<Integer> ids = getTabIdList();
177             int position = ids.indexOf(tabId);
178             if (position == 0) return ids.get(position + 1);
179             return ids.get(position - 1);
180         }
181 
getTabIdForIndex(int index)182         int getTabIdForIndex(int index) {
183             return getTabIdList().get(index);
184         }
185     }
186     private ObserverList<Observer> mGroupFilterObserver = new ObserverList<>();
187     private Map<Integer, Integer> mGroupIdToGroupIndexMap = new HashMap<>();
188     private Map<Integer, TabGroup> mGroupIdToGroupMap = new HashMap<>();
189     private int mCurrentGroupIndex = TabList.INVALID_TAB_INDEX;
190     // The number of groups with at least 2 tabs.
191     private int mActualGroupCount;
192     private Tab mAbsentSelectedTab;
193     private boolean mShouldRecordUma = true;
194     private boolean mIsResetting;
195 
TabGroupModelFilter(TabModel tabModel)196     public TabGroupModelFilter(TabModel tabModel) {
197         super(tabModel);
198     }
199 
200     /**
201      * This method adds a {@link Observer} to be notified on {@link TabGroupModelFilter} changes.
202      * @param observer The {@link Observer} to add.
203      */
addTabGroupObserver(Observer observer)204     public void addTabGroupObserver(Observer observer) {
205         mGroupFilterObserver.addObserver(observer);
206     }
207 
208     /**
209      * This method removes a {@link Observer}.
210      * @param observer The {@link Observer} to remove.
211      */
removeTabGroupObserver(Observer observer)212     public void removeTabGroupObserver(Observer observer) {
213         mGroupFilterObserver.removeObserver(observer);
214     }
215 
216     /**
217      * @return Number of {@link TabGroup}s that has at least two tabs.
218      */
getTabGroupCount()219     public int getTabGroupCount() {
220         return mActualGroupCount;
221     }
222 
223     /**
224      * This method records the number of sessions of the provided {@link Tab}, only if that
225      * {@link Tab} is in a group that has at least two tab, and it records as
226      * "TabGroups.SessionPerGroup".
227      * @param tab {@link Tab}
228      */
recordSessionsCount(Tab tab)229     public void recordSessionsCount(Tab tab) {
230         int groupId = getRootId(tab);
231         boolean isActualGroup = mGroupIdToGroupMap.get(groupId) != null
232                 && mGroupIdToGroupMap.get(groupId).size() > 1;
233         if (!isActualGroup) return;
234 
235         AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
236             int sessionsCount = updateAndGetSessionsCount(groupId);
237             RecordHistogram.recordCountHistogram("TabGroups.SessionsPerGroup", sessionsCount);
238         });
239     }
240 
241     /**
242      * This method moves the TabGroup which contains the Tab with TabId {@code id} to
243      * {@code newIndex} in TabModel.
244      * @param id         The id of the tab whose related tabs are being moved.
245      * @param newIndex   The new index in TabModel that these tabs are being moved to.
246      */
moveRelatedTabs(int id, int newIndex)247     public void moveRelatedTabs(int id, int newIndex) {
248         List<Tab> tabs = getRelatedTabList(id);
249         TabModel tabModel = getTabModel();
250         newIndex = MathUtils.clamp(newIndex, 0, tabModel.getCount());
251         int curIndex = TabModelUtils.getTabIndexById(tabModel, tabs.get(0).getId());
252 
253         if (curIndex == INVALID_TAB_INDEX || curIndex == newIndex) {
254             return;
255         }
256 
257         int offset = 0;
258         for (Tab tab : tabs) {
259             if (tabModel.indexOf(tab) == -1) {
260                 assert false : "Tried to close a tab from another model!";
261                 continue;
262             }
263             tabModel.moveTab(tab.getId(), newIndex >= curIndex ? newIndex : newIndex + offset++);
264         }
265     }
266 
267     /**
268      * This method merges the source group that contains the {@code sourceTabId} to the destination
269      * group that contains the {@code destinationTabId}. This method only operates if two groups are
270      * in the same {@code TabModel}.
271      *
272      * @param sourceTabId The id of the {@link Tab} to get the source group.
273      * @param destinationTabId The id of a {@link Tab} to get the destination group.
274      */
mergeTabsToGroup(int sourceTabId, int destinationTabId)275     public void mergeTabsToGroup(int sourceTabId, int destinationTabId) {
276         Tab sourceTab = TabModelUtils.getTabById(getTabModel(), sourceTabId);
277         Tab destinationTab = TabModelUtils.getTabById(getTabModel(), destinationTabId);
278 
279         assert sourceTab != null && destinationTab != null
280                 && sourceTab.isIncognito()
281                         == destinationTab.isIncognito()
282             : "Attempting to merge groups from different model";
283 
284         int destinationGroupId = getRootId(destinationTab);
285         List<Tab> tabsToMerge = getRelatedTabList(sourceTabId);
286         int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);
287 
288         if (!needToUpdateTabModel(tabsToMerge, destinationIndexInTabModel)) {
289             for (Observer observer : mGroupFilterObserver) {
290                 observer.willMergeTabToGroup(
291                         tabsToMerge.get(tabsToMerge.size() - 1), destinationGroupId);
292             }
293             for (int i = 0; i < tabsToMerge.size(); i++) {
294                 setRootId(tabsToMerge.get(i), destinationGroupId);
295             }
296             resetFilterState();
297 
298             Tab lastMergedTab = tabsToMerge.get(tabsToMerge.size() - 1);
299             TabGroup group = mGroupIdToGroupMap.get(getRootId(lastMergedTab));
300             for (Observer observer : mGroupFilterObserver) {
301                 observer.didMergeTabToGroup(
302                         tabsToMerge.get(tabsToMerge.size() - 1), group.getLastShownTabId());
303             }
304         } else {
305             mergeListOfTabsToGroup(tabsToMerge, destinationTab, true, false);
306         }
307         // TODO(978508): Send didCreateGroup signal to activate the
308         // {@link UndoGroupSnackbarController}.
309     }
310 
311     /**
312      * This method appends a list of {@link Tab}s to the destination group that contains the
313      * {@code} destinationTab. The {@link TabModel} ordering of the tabs in the given list is not
314      * preserved. After calling this method, the {@link TabModel} ordering of these tabs would
315      * become the ordering of {@code tabs}.
316      *
317      * @param tabs List of {@link Tab}s to be appended.
318      * @param destinationTab The destination {@link Tab} to be append to.
319      * @param isSameGroup Whether the given list of {@link Tab}s belongs in the same group
320      *                    originally.
321      * @param notify Whether or not to notify observers about the merging events.
322      */
mergeListOfTabsToGroup( List<Tab> tabs, Tab destinationTab, boolean isSameGroup, boolean notify)323     public void mergeListOfTabsToGroup(
324             List<Tab> tabs, Tab destinationTab, boolean isSameGroup, boolean notify) {
325         int destinationGroupId = getRootId(destinationTab);
326         int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);
327         List<Integer> originalIndexes = new ArrayList<>();
328 
329         for (int i = 0; i < tabs.size(); i++) {
330             Tab tab = tabs.get(i);
331             // When merging tabs are in the same group, only make one willMergeTabToGroup call.
332             if (!isSameGroup || i == tabs.size() - 1) {
333                 for (Observer observer : mGroupFilterObserver) {
334                     observer.willMergeTabToGroup(tab, destinationGroupId);
335                 }
336             }
337             int index = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
338             assert index != TabModel.INVALID_TAB_INDEX;
339             originalIndexes.add(index);
340 
341             if (tab.getId() == destinationTab.getId()) continue;
342 
343             boolean isMergingBackward = index < destinationIndexInTabModel;
344 
345             setRootId(tab, destinationGroupId);
346             getTabModel().moveTab(tab.getId(),
347                     isMergingBackward ? destinationIndexInTabModel : destinationIndexInTabModel++);
348         }
349 
350         if (notify) {
351             for (Observer observer : mGroupFilterObserver) {
352                 observer.didCreateGroup(tabs, originalIndexes, isSameGroup);
353             }
354         }
355     }
356 
357     /**
358      * This method moves Tab with id as {@code sourceTabId} out of the group it belongs to.
359      *
360      * @param sourceTabId The id of the {@link Tab} to get the source group.
361      */
362     public void moveTabOutOfGroup(int sourceTabId) {
363         TabModel tabModel = getTabModel();
364         Tab sourceTab = TabModelUtils.getTabById(tabModel, sourceTabId);
365         int sourceIndex = tabModel.indexOf(sourceTab);
366         TabGroup sourceTabGroup = mGroupIdToGroupMap.get(getRootId(sourceTab));
367         Tab lastTabInSourceGroup = TabModelUtils.getTabById(tabModel,
368                 sourceTabGroup.getTabIdForIndex(sourceTabGroup.getTabIdList().size() - 1));
369         int targetIndex = tabModel.indexOf(lastTabInSourceGroup);
370         assert targetIndex != TabModel.INVALID_TAB_INDEX;
371 
372         int prevFilterIndex = mGroupIdToGroupIndexMap.get(getRootId(sourceTab));
373         if (sourceTabGroup.size() == 1) {
374             for (Observer observer : mGroupFilterObserver) {
375                 observer.didMoveTabOutOfGroup(sourceTab, prevFilterIndex);
376             }
377             return;
378         }
379         int newRootId = getRootId(sourceTab);
380         if (sourceTab.getId() == getRootId(sourceTab)) {
381             // If moving tab's id is the root id of the group, find a new root id.
382             if (sourceIndex != 0
383                     && getRootId(tabModel.getTabAt(sourceIndex - 1)) == getRootId(sourceTab)) {
384                 newRootId = tabModel.getTabAt(sourceIndex - 1).getId();
385             } else if (sourceIndex != tabModel.getCount() - 1
386                     && getRootId(tabModel.getTabAt(sourceIndex + 1)) == getRootId(sourceTab)) {
387                 newRootId = tabModel.getTabAt(sourceIndex + 1).getId();
388             }
389         }
390         assert newRootId != Tab.INVALID_TAB_ID;
391 
392         for (Observer observer : mGroupFilterObserver) {
393             observer.willMoveTabOutOfGroup(sourceTab, newRootId);
394         }
395         if (sourceTab.getId() == getRootId(sourceTab)) {
396             for (int tabId : sourceTabGroup.getTabIdList()) {
397                 setRootId(TabModelUtils.getTabById(tabModel, tabId), newRootId);
398             }
399             resetFilterState();
400         }
401         setRootId(sourceTab, sourceTab.getId());
402         // If moving tab is already in the target index in tab model, no move in tab model.
403         if (sourceIndex == targetIndex) {
404             resetFilterState();
405             for (Observer observer : mGroupFilterObserver) {
406                 observer.didMoveTabOutOfGroup(sourceTab, prevFilterIndex);
407             }
408             return;
409         }
410         // Plus one as offset because we are moving backwards in tab model.
411         tabModel.moveTab(sourceTab.getId(), targetIndex + 1);
412     }
413 
414     private int getTabModelDestinationIndex(Tab destinationTab) {
415         List<Integer> destinationGroupedTabIds =
416                 mGroupIdToGroupMap.get(getRootId(destinationTab)).getTabIdList();
417         int destinationTabIndex = TabModelUtils.getTabIndexById(
418                 getTabModel(), destinationGroupedTabIds.get(destinationGroupedTabIds.size() - 1));
419 
420         return destinationTabIndex + 1;
421     }
422 
423     private boolean needToUpdateTabModel(List<Tab> tabsToMerge, int destinationIndexInTabModel) {
424         assert tabsToMerge.size() > 0;
425 
426         int firstTabIndexInTabModel =
427                 TabModelUtils.getTabIndexById(getTabModel(), tabsToMerge.get(0).getId());
428         return firstTabIndexInTabModel != destinationIndexInTabModel;
429     }
430 
431     /**
432      * This method undo the given grouped {@link Tab}.
433      *
434      * @param tab undo this grouped {@link Tab}.
435      * @param originalIndex The tab index before grouped.
436      * @param originalGroupId The rootId before grouped.
437      */
undoGroupedTab(Tab tab, int originalIndex, int originalGroupId)438     public void undoGroupedTab(Tab tab, int originalIndex, int originalGroupId) {
439         if (!tab.isInitialized()) {
440             return;
441         }
442         int currentIndex = TabModelUtils.getTabIndexById(getTabModel(), tab.getId());
443         assert currentIndex != TabModel.INVALID_TAB_INDEX;
444 
445         setRootId(tab, originalGroupId);
446         if (currentIndex == originalIndex) {
447             didMoveTab(tab, originalIndex, currentIndex);
448         } else {
449             if (currentIndex < originalIndex) originalIndex++;
450             getTabModel().moveTab(tab.getId(), originalIndex);
451         }
452     }
453 
454     // TODO(crbug.com/951608): follow up with sessions count histogram for TabGroups.
updateAndGetSessionsCount(int groupId)455     private int updateAndGetSessionsCount(int groupId) {
456         ThreadUtils.assertOnBackgroundThread();
457 
458         String sessionsCountForGroupKey = SESSIONS_COUNT_FOR_GROUP + Integer.toString(groupId);
459         SharedPreferences prefs = getSharedPreferences();
460         int sessionsCount = prefs.getInt(sessionsCountForGroupKey, 0);
461         sessionsCount++;
462         prefs.edit().putInt(sessionsCountForGroupKey, sessionsCount).apply();
463         return sessionsCount;
464     }
465 
getSharedPreferences()466     private SharedPreferences getSharedPreferences() {
467         if (sPref == null) {
468             sPref = ContextUtils.getApplicationContext().getSharedPreferences(
469                     PREFS_FILE, Context.MODE_PRIVATE);
470         }
471         return sPref;
472     }
473 
474     // TabModelFilter implementation.
475     @NonNull
476     @Override
getRelatedTabList(int id)477     public List<Tab> getRelatedTabList(int id) {
478         // TODO(meiliang): In worst case, this method runs in O(n^2). This method needs to perform
479         // better, especially when we try to call it in a loop for all tabs.
480         Tab tab = TabModelUtils.getTabById(getTabModel(), id);
481         if (tab == null) return super.getRelatedTabList(id);
482 
483         int groupId = getRootId(tab);
484         TabGroup group = mGroupIdToGroupMap.get(groupId);
485         if (group == null) return super.getRelatedTabList(TabModel.INVALID_TAB_INDEX);
486         return getRelatedTabList(group.getTabIdList());
487     }
488 
489     /**
490      * This method returns all tabs in a tab group with reference to {@code tabRootId} as group id.
491      *
492      * @param tabRootId The tab root id that is used to find the related group.
493      * @return An unmodifiable list of {@link Tab} that relate with the given tab root id.
494      */
getRelatedTabListForRootId(int tabRootId)495     public List<Tab> getRelatedTabListForRootId(int tabRootId) {
496         if (tabRootId == Tab.INVALID_TAB_ID) return super.getRelatedTabList(tabRootId);
497         TabGroup group = mGroupIdToGroupMap.get(tabRootId);
498         if (group == null) return super.getRelatedTabList(TabModel.INVALID_TAB_INDEX);
499         return getRelatedTabList(group.getTabIdList());
500     }
501 
502     @Override
hasOtherRelatedTabs(Tab tab)503     public boolean hasOtherRelatedTabs(Tab tab) {
504         int groupId = getRootId(tab);
505         TabGroup group = mGroupIdToGroupMap.get(groupId);
506         return group != null && group.size() > 1;
507     }
508 
getRelatedTabList(List<Integer> ids)509     private List<Tab> getRelatedTabList(List<Integer> ids) {
510         List<Tab> tabs = new ArrayList<>();
511         for (Integer id : ids) {
512             tabs.add(TabModelUtils.getTabById(getTabModel(), id));
513         }
514         return Collections.unmodifiableList(tabs);
515     }
516 
517     @Override
addTab(Tab tab)518     protected void addTab(Tab tab) {
519         if (tab.isIncognito() != isIncognito()) {
520             throw new IllegalStateException("Attempting to open tab in the wrong model");
521         }
522 
523         if (isTabModelRestored() && !mIsResetting) {
524             Tab parentTab = TabModelUtils.getTabById(
525                     getTabModel(), CriticalPersistedTabData.from(tab).getParentId());
526             if (parentTab != null) {
527                 setRootId(tab, getRootId(parentTab));
528             }
529         }
530 
531         int groupId = getRootId(tab);
532         if (mGroupIdToGroupMap.containsKey(groupId)) {
533             if (mGroupIdToGroupMap.get(groupId).size() == 1) {
534                 mActualGroupCount++;
535                 if (mShouldRecordUma
536                         && tab.getLaunchType() == TabLaunchType.FROM_LONGPRESS_BACKGROUND) {
537                     RecordUserAction.record("TabGroup.Created.OpenInNewTab");
538                 }
539             }
540             mGroupIdToGroupMap.get(groupId).addTab(tab.getId());
541         } else {
542             TabGroup tabGroup = new TabGroup(getRootId(tab));
543             tabGroup.addTab(tab.getId());
544             mGroupIdToGroupMap.put(groupId, tabGroup);
545             mGroupIdToGroupIndexMap.put(groupId, mGroupIdToGroupIndexMap.size());
546         }
547 
548         if (mAbsentSelectedTab != null) {
549             Tab absentSelectedTab = mAbsentSelectedTab;
550             mAbsentSelectedTab = null;
551             selectTab(absentSelectedTab);
552         }
553     }
554 
555     @Override
closeTab(Tab tab)556     protected void closeTab(Tab tab) {
557         int groupId = getRootId(tab);
558         if (tab.isIncognito() != isIncognito() || mGroupIdToGroupMap.get(groupId) == null
559                 || !mGroupIdToGroupMap.get(groupId).contains(tab.getId())) {
560             throw new IllegalStateException("Attempting to close tab in the wrong model");
561         }
562 
563         TabGroup group = mGroupIdToGroupMap.get(groupId);
564         group.removeTab(tab.getId());
565         if (group.size() == 1) mActualGroupCount--;
566         if (group.size() == 0) {
567             updateGroupIdToGroupIndexMapAfterGroupClosed(groupId);
568             mGroupIdToGroupIndexMap.remove(groupId);
569             mGroupIdToGroupMap.remove(groupId);
570             AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> removeGroupFromPref(groupId));
571         }
572     }
573 
removeGroupFromPref(int groupId)574     private void removeGroupFromPref(int groupId) {
575         ThreadUtils.assertOnBackgroundThread();
576 
577         SharedPreferences prefs = getSharedPreferences();
578         String key = SESSIONS_COUNT_FOR_GROUP + Integer.toString(groupId);
579         if (prefs.contains(key)) {
580             prefs.edit().remove(key).apply();
581         }
582     }
583 
updateGroupIdToGroupIndexMapAfterGroupClosed(int groupId)584     private void updateGroupIdToGroupIndexMapAfterGroupClosed(int groupId) {
585         int indexToRemove = mGroupIdToGroupIndexMap.get(groupId);
586         Set<Integer> groupIdSet = mGroupIdToGroupIndexMap.keySet();
587         for (Integer groupIdKey : groupIdSet) {
588             int groupIndex = mGroupIdToGroupIndexMap.get(groupIdKey);
589             if (groupIndex > indexToRemove) {
590                 mGroupIdToGroupIndexMap.put(groupIdKey, groupIndex - 1);
591             }
592         }
593     }
594 
595     @Override
selectTab(Tab tab)596     protected void selectTab(Tab tab) {
597         assert mAbsentSelectedTab == null;
598         int groupId = getRootId(tab);
599         if (mGroupIdToGroupMap.get(groupId) == null) {
600             mAbsentSelectedTab = tab;
601         } else {
602             mGroupIdToGroupMap.get(groupId).setLastShownTabId(tab.getId());
603             mCurrentGroupIndex = mGroupIdToGroupIndexMap.get(groupId);
604         }
605     }
606 
607     @Override
reorder()608     protected void reorder() {
609         reorderGroup(TabGroup.INVALID_GROUP_ID);
610 
611         TabModel tabModel = getTabModel();
612         if (tabModel.index() == TabModel.INVALID_TAB_INDEX) {
613             mCurrentGroupIndex = TabModel.INVALID_TAB_INDEX;
614         } else {
615             selectTab(tabModel.getTabAt(tabModel.index()));
616         }
617 
618         assert mGroupIdToGroupIndexMap.size() == mGroupIdToGroupMap.size();
619     }
620 
reorderGroup(int groupId)621     private void reorderGroup(int groupId) {
622         boolean reorderAllGroups = groupId == TabGroup.INVALID_GROUP_ID;
623         if (reorderAllGroups) {
624             mGroupIdToGroupIndexMap.clear();
625         }
626 
627         TabModel tabModel = getTabModel();
628         for (int i = 0; i < tabModel.getCount(); i++) {
629             Tab tab = tabModel.getTabAt(i);
630             if (reorderAllGroups) {
631                 groupId = getRootId(tab);
632                 if (!mGroupIdToGroupIndexMap.containsKey(groupId)) {
633                     mGroupIdToGroupIndexMap.put(groupId, mGroupIdToGroupIndexMap.size());
634                 }
635             }
636             mGroupIdToGroupMap.get(groupId).moveToEndInGroup(tab.getId());
637         }
638     }
639 
640     @Override
resetFilterStateInternal()641     protected void resetFilterStateInternal() {
642         mGroupIdToGroupIndexMap.clear();
643         mGroupIdToGroupMap.clear();
644         mActualGroupCount = 0;
645     }
646 
647     @Override
removeTab(Tab tab)648     protected void removeTab(Tab tab) {
649         closeTab(tab);
650     }
651 
652     @Override
resetFilterState()653     protected void resetFilterState() {
654         mShouldRecordUma = false;
655         mIsResetting = true;
656         Map<Integer, Integer> groupIdToGroupLastShownTabId = new HashMap<>();
657         for (int groupId : mGroupIdToGroupMap.keySet()) {
658             groupIdToGroupLastShownTabId.put(
659                     groupId, mGroupIdToGroupMap.get(groupId).getLastShownTabId());
660         }
661 
662         super.resetFilterState();
663 
664         // Restore previous last shown tab ids after resetting filter state.
665         for (int groupId : mGroupIdToGroupMap.keySet()) {
666             // This happens when group with new groupId is formed after resetting filter state, i.e.
667             // when ungroup happens. Restoring last shown id of newly generated group is ignored.
668             if (!groupIdToGroupLastShownTabId.containsKey(groupId)) continue;
669             int lastShownId = groupIdToGroupLastShownTabId.get(groupId);
670             // This happens during continuous resetFilterState() calls caused by merging multiple
671             // tabs. Ignore the calls where the merge is not completed but the last shown tab has
672             // already been merged to new group.
673             if (!mGroupIdToGroupMap.get(groupId).contains(lastShownId)) continue;
674             mGroupIdToGroupMap.get(groupId).setLastShownTabId(lastShownId);
675         }
676         TabModel tabModel = getTabModel();
677         if (tabModel.index() == TabModel.INVALID_TAB_INDEX) {
678             mCurrentGroupIndex = TabModel.INVALID_TAB_INDEX;
679         } else {
680             selectTab(tabModel.getTabAt(tabModel.index()));
681         }
682         mShouldRecordUma = true;
683         mIsResetting = false;
684     }
685 
686     @Override
shouldNotifyObserversOnSetIndex()687     protected boolean shouldNotifyObserversOnSetIndex() {
688         return mAbsentSelectedTab == null;
689     }
690 
691     @Override
didMoveTab(Tab tab, int newIndex, int curIndex)692     public void didMoveTab(Tab tab, int newIndex, int curIndex) {
693         // Ignore didMoveTab calls in tab restoring stage.
694         if (!isTabModelRestored()) return;
695         // Need to cache the flags before resetting the internal data map.
696         boolean isMergeTabToGroup = isMergeTabToGroup(tab);
697         boolean isMoveTabOutOfGroup = isMoveTabOutOfGroup(tab);
698         int groupIdBeforeMove = getGroupIdBeforeMove(tab, isMergeTabToGroup || isMoveTabOutOfGroup);
699         assert groupIdBeforeMove != TabGroup.INVALID_GROUP_ID;
700         TabGroup groupBeforeMove = mGroupIdToGroupMap.get(groupIdBeforeMove);
701 
702         if (isMoveTabOutOfGroup) {
703             resetFilterState();
704 
705             int prevFilterIndex = mGroupIdToGroupIndexMap.get(groupIdBeforeMove);
706             for (Observer observer : mGroupFilterObserver) {
707                 observer.didMoveTabOutOfGroup(tab, prevFilterIndex);
708             }
709         } else if (isMergeTabToGroup) {
710             resetFilterState();
711             if (groupBeforeMove != null && groupBeforeMove.size() != 1) return;
712 
713             TabGroup group = mGroupIdToGroupMap.get(getRootId(tab));
714             for (Observer observer : mGroupFilterObserver) {
715                 observer.didMergeTabToGroup(tab, group.getLastShownTabId());
716             }
717         } else {
718             reorder();
719             if (isMoveWithinGroup(tab, curIndex, newIndex)) {
720                 for (Observer observer : mGroupFilterObserver) {
721                     observer.didMoveWithinGroup(tab, curIndex, newIndex);
722                 }
723             } else {
724                 if (!hasFinishedMovingGroup(tab, newIndex)) return;
725                 for (Observer observer : mGroupFilterObserver) {
726                     observer.didMoveTabGroup(tab, curIndex, newIndex);
727                 }
728             }
729         }
730 
731         super.didMoveTab(tab, newIndex, curIndex);
732     }
733 
setRootId(Tab tab, int id)734     private static void setRootId(Tab tab, int id) {
735         CriticalPersistedTabData.from(tab).setRootId(id);
736     }
737 
getRootId(Tab tab)738     private static int getRootId(Tab tab) {
739         return CriticalPersistedTabData.from(tab).getRootId();
740     }
741 
isMoveTabOutOfGroup(Tab movedTab)742     private boolean isMoveTabOutOfGroup(Tab movedTab) {
743         return !mGroupIdToGroupMap.containsKey(getRootId(movedTab));
744     }
745 
isMergeTabToGroup(Tab tab)746     private boolean isMergeTabToGroup(Tab tab) {
747         if (!mGroupIdToGroupMap.containsKey(getRootId(tab))) return false;
748         TabGroup tabGroup = mGroupIdToGroupMap.get(getRootId(tab));
749         return !tabGroup.contains(tab.getId());
750     }
751 
getGroupIdBeforeMove(Tab tabToMove, boolean isMoveToDifferentGroup)752     private int getGroupIdBeforeMove(Tab tabToMove, boolean isMoveToDifferentGroup) {
753         if (!isMoveToDifferentGroup) return getRootId(tabToMove);
754 
755         Set<Integer> groupIdSet = mGroupIdToGroupMap.keySet();
756         for (Integer groupIdKey : groupIdSet) {
757             if (mGroupIdToGroupMap.get(groupIdKey).contains(tabToMove.getId())) {
758                 return groupIdKey;
759             }
760         }
761 
762         return TabGroup.INVALID_GROUP_ID;
763     }
764 
isMoveWithinGroup( Tab movedTab, int oldIndexInTabModel, int newIndexInTabModel)765     private boolean isMoveWithinGroup(
766             Tab movedTab, int oldIndexInTabModel, int newIndexInTabModel) {
767         int startIndex = Math.min(oldIndexInTabModel, newIndexInTabModel);
768         int endIndex = Math.max(oldIndexInTabModel, newIndexInTabModel);
769         for (int i = startIndex; i <= endIndex; i++) {
770             if (getRootId(getTabModel().getTabAt(i)) != getRootId(movedTab)) return false;
771         }
772         return true;
773     }
774 
hasFinishedMovingGroup(Tab movedTab, int newIndexInTabModel)775     private boolean hasFinishedMovingGroup(Tab movedTab, int newIndexInTabModel) {
776         TabGroup tabGroup = mGroupIdToGroupMap.get(getRootId(movedTab));
777         int offsetIndex = newIndexInTabModel - tabGroup.size() + 1;
778         if (offsetIndex < 0) return false;
779 
780         for (int i = newIndexInTabModel; i >= offsetIndex; i--) {
781             if (getRootId(getTabModel().getTabAt(i)) != getRootId(movedTab)) return false;
782         }
783         return true;
784     }
785 
786     // TabList implementation.
787     @Override
isIncognito()788     public boolean isIncognito() {
789         return getTabModel().isIncognito();
790     }
791 
792     @Override
index()793     public int index() {
794         return mCurrentGroupIndex;
795     }
796 
797     @Override
getCount()798     public int getCount() {
799         return mGroupIdToGroupMap.size();
800     }
801 
802     @Override
getTabAt(int index)803     public Tab getTabAt(int index) {
804         if (index < 0 || index >= getCount()) return null;
805         int groupId = Tab.INVALID_TAB_ID;
806         Set<Integer> groupIdSet = mGroupIdToGroupIndexMap.keySet();
807         for (Integer groupIdKey : groupIdSet) {
808             if (mGroupIdToGroupIndexMap.get(groupIdKey) == index) {
809                 groupId = groupIdKey;
810                 break;
811             }
812         }
813         if (groupId == Tab.INVALID_TAB_ID) return null;
814 
815         return TabModelUtils.getTabById(
816                 getTabModel(), mGroupIdToGroupMap.get(groupId).getLastShownTabId());
817     }
818 
819     @Override
indexOf(Tab tab)820     public int indexOf(Tab tab) {
821         if (tab == null || tab.isIncognito() != isIncognito()
822                 || getTabModel().indexOf(tab) == TabList.INVALID_TAB_INDEX) {
823             return TabList.INVALID_TAB_INDEX;
824         }
825 
826         int groupId = getRootId(tab);
827         if (!mGroupIdToGroupIndexMap.containsKey(groupId)) return TabList.INVALID_TAB_INDEX;
828         return mGroupIdToGroupIndexMap.get(groupId);
829     }
830 
831     @Override
isClosurePending(int tabId)832     public boolean isClosurePending(int tabId) {
833         return getTabModel().isClosurePending(tabId);
834     }
835 
836     @VisibleForTesting
getGroupLastShownTabIdForTesting(int groupId)837     int getGroupLastShownTabIdForTesting(int groupId) {
838         return mGroupIdToGroupMap.get(groupId).getLastShownTabId();
839     }
840 }
841