// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.tabmodel;
import androidx.annotation.NonNull;
import org.chromium.base.ThreadUtils.ThreadChecker;
import org.chromium.chrome.browser.tab.Tab;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
/**
* Manages the logic pertaining to tracking pending tab closures for a {@link TabModelImpl}.
* This class does not directly perform any tab related actions and delegates that work
* to the {@link PendingTabClosureDelegate} it is provided.
*/
public class PendingTabClosureManager {
/**
* Delegate for applying changes to a {@link TabList} based on the decision logic in
* {@link PendingTabClosureManager}.
*/
public interface PendingTabClosureDelegate {
/**
* Return {@code tab} to the {@link TabList} at {@code index}.
*
* @param tab The tab to insert.
* @param index The location to insert the tab at.
*/
void insertUndoneTabClosureAt(Tab tab, int index);
/**
* Finalize the closure of a Tab.
*
* @param tab The tab to finalize the closure of.
*/
void finalizeClosure(Tab tab);
/** Notify observers about completion of undo action to restore all tabs. */
void notifyAllTabsClosureUndone();
/**
* Request to notify observers that {@code tabs} will be closed.
*
* @param tabs The list of tabs to close together.
*/
void notifyOnFinishingMultipleTabClosure(List<Tab> tabs);
}
/** Represents a set of tabs closed together. */
private class TabClosureEvent {
private final LinkedList<Tab> mClosingTabs;
private final HashSet<Tab> mUnhandledTabs;
/**
* @param tabs The list of closing tabs.
*/
public TabClosureEvent(List<Tab> tabs) {
mClosingTabs = new LinkedList<>(tabs);
mUnhandledTabs = new HashSet<>(mClosingTabs);
}
/**
* @param tab The tab to mark as having closed.
*/
public boolean markReadyToCommit(Tab tab) {
return mUnhandledTabs.remove(tab);
}
/**
* @param tab The tab to mark as having been cancelled.
*/
public boolean markCancelled(Tab tab) {
final boolean removed = mUnhandledTabs.remove(tab);
if (removed) {
mClosingTabs.remove(tab);
}
return removed;
}
/**
* @return true once all tabs have been marked as ready to commit or were cancelled.
*/
public boolean allTabsHandled() {
return mUnhandledTabs.isEmpty();
}
/**
* @return the list of tabs marked as closing in this event.
*/
public LinkedList<Tab> getList() {
return mClosingTabs;
}
}
private class RewoundList implements TabList {
/**
* A list of {@link Tab}s that represents the completely rewound list (if all rewindable
* closes were undone). If there are no possible rewindable closes this list should match
* {@link #mTabs}.
*/
private final List<Tab> mRewoundTabs = new ArrayList<>();
@Override
public boolean isIncognito() {
return mTabModel.isIncognito();
}
@Override
public boolean isOffTheRecord() {
return mTabModel.isOffTheRecord();
}
@Override
public boolean isIncognitoBranded() {
return mTabModel.isIncognitoBranded();
}
/**
* If {@link TabList} has a valid selected tab, this will return that same tab in the
* context of the rewound list of tabs. If {@link TabList} has no tabs but the rewound list
* is not empty, it will return 0, the first tab. Otherwise it will return {@link
* TabList#INVALID_TAB_INDEX}.
*
* @return The selected index of the rewound list of tabs (includes all pending closures).
*/
@Override
public int index() {
if (mTabModel.index() != INVALID_TAB_INDEX) {
return mRewoundTabs.indexOf(TabModelUtils.getCurrentTab(mTabModel));
}
if (!mRewoundTabs.isEmpty()) return 0;
return INVALID_TAB_INDEX;
}
@Override
public int getCount() {
return mRewoundTabs.size();
}
@Override
public Tab getTabAt(int index) {
if (index < 0 || index >= mRewoundTabs.size()) return null;
return mRewoundTabs.get(index);
}
@Override
public int indexOf(Tab tab) {
return mRewoundTabs.indexOf(tab);
}
/**
* Resets this list to match the original {@link TabList}. Note that if the
* {@link TabList} doesn't support pending closures this model will be empty. This should
* be called whenever {@link TabList}'s list of tabs changes.
*/
public void resetRewoundState() {
mRewoundTabs.clear();
for (int i = 0; i < mTabModel.getCount(); i++) {
mRewoundTabs.add(mTabModel.getTabAt(i));
}
}
/**
* Finds the {@link Tab} specified by {@code tabId} and only returns it if it is actually a
* {@link Tab} that is in the middle of being closed (which means that it is present in this
* model but not in {@code mTabModel}.
*
* @param tabId The id of the {@link Tab} to search for.
* @return The {@link Tab} specified by {@code tabId} as long as that tab only exists in
* this model and not in {@code mTabModel}. {@code null} otherwise.
*/
public Tab getPendingRewindTab(int tabId) {
if (mTabModel.getTabById(tabId) != null) return null;
for (Tab tab : mRewoundTabs) {
if (tab.getId() == tabId) return tab;
}
return null;
}
/**
* Removes a {@link Tab} from this internal list.
*
* @param tab The {@link Tab} to remove.
* @return whether the tab was removed.
*/
public boolean removeTab(Tab tab) {
return mRewoundTabs.remove(tab);
}
/**
* Destroy all tabs in this model. This will check to see if the tab is already destroyed
* before destroying it.
*/
public void destroy() {
// All tabs pending closure are committed in TabModelImpl#destroy.
for (Tab tab : mRewoundTabs) {
if (tab.isInitialized()) tab.destroy();
}
mRewoundTabs.clear();
}
public boolean hasPendingClosures() {
return mRewoundTabs.size() > mTabModel.getCount();
}
}
/** Thread checks to root cause crbug.com/1465745. */
private final ThreadChecker mThreadChecker = new ThreadChecker();
private boolean mIsCommittingAllTabClosures;
/** The {@link TabModel} that this {@link PendingTabClosureManager} operates on. */
private TabModel mTabModel;
private PendingTabClosureDelegate mDelegate;
/** Representation of a set of tabs that were closed together. */
private LinkedList<TabClosureEvent> mTabClosureEvents = new LinkedList<>();
/**
* A {@link TabList} that represents the complete list of {@link Tab}s. This is so that
* certain UI elements can call {@link TabModel#getComprehensiveModel()} to get a full list of
* {@link Tab}s that includes rewindable entries, as the typical {@link TabModel} does not
* return rewindable entries.
*/
private final RewoundList mRewoundList = new RewoundList();
/**
* @param tabModel The {@link TabModel} that this manages closing for.
* @param delegate A {@link PendingTabClosureDelegate} to use to apply cancelled and committed
* tab closures.
*/
public PendingTabClosureManager(
@NonNull TabModel tabModel, @NonNull PendingTabClosureDelegate delegate) {
assert tabModel != null;
assert delegate != null;
mTabModel = tabModel;
mDelegate = delegate;
}
public void destroy() {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
mRewoundList.destroy();
mTabClosureEvents.clear();
}
public void destroyWhileReparentingInProgress() {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
mTabClosureEvents.clear();
}
/** Resets the state of the rewound list based on {@code mTabModel}. */
public void resetState() {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
assert mTabClosureEvents.isEmpty();
mRewoundList.resetRewoundState();
}
/**
* Creates a new closure event when pending tabs are closed.
* @param tabs The list of {@link Tab} that are closing.
*/
public void addTabClosureEvent(List<Tab> tabs) {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
mTabClosureEvents.add(new TabClosureEvent(tabs));
}
/**
* @return the list of rewindable tabs.
*/
public TabList getRewoundList() {
return mRewoundList;
}
/**
* @param tabId The ID of the {@link Tab} to search for.
* @return whether the list of rewindable tabs contains {@code tabId}.
*/
public boolean isClosurePending(int tabId) {
return mRewoundList.getPendingRewindTab(tabId) != null;
}
/**
* Marks a {@link Tab} as ready to commit. If it is the last tab of a {@link TabClosureEvent}
* to be "ready to commit" then the {@link TabClosureEvent} will commit all tabs as closed.
* @param tabId The ID of the {@link Tab} to mark as ready to commit.
*/
public void commitTabClosure(int tabId) {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
Tab tab = mRewoundList.getPendingRewindTab(tabId);
if (tab == null) return;
ListIterator<TabClosureEvent> events = mTabClosureEvents.listIterator();
while (events.hasNext()) {
TabClosureEvent event = events.next();
if (!event.markReadyToCommit(tab)) continue;
if (event.allTabsHandled()) {
events.remove();
commitClosuresInternal(event.getList());
}
break;
}
}
/**
* Marks a {@link Tab} as cancelled and restores it to the {@code mTabModel}.
*
* @param tabId The ID of the {@link Tab} to cancel the closure of.
*/
public void cancelTabClosure(int tabId) {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
Tab tab = mRewoundList.getPendingRewindTab(tabId);
if (tab == null) return;
ListIterator<TabClosureEvent> events = mTabClosureEvents.listIterator();
while (events.hasNext()) {
TabClosureEvent event = events.next();
if (!event.markCancelled(tab)) continue;
// Bulk undoing closures is messy so just do it one by one.
cancelClosureInternal(tab);
// Remove the event once all tabs in it are gone.
if (event.allTabsHandled()) {
events.remove();
List<Tab> closingTabs = event.getList();
if (!closingTabs.isEmpty()) {
commitClosuresInternal(closingTabs);
}
}
break;
}
}
/** Notify observers about completion of undo action to restore all tabs. */
public void notifyAllTabsClosureUndone() {
mDelegate.notifyAllTabsClosureUndone();
}
/**
* Commits all tab closures in the order in which {@link #addTabClosureEvent(List<Tab>)} was
* called.
*/
public void commitAllTabClosures() {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
mIsCommittingAllTabClosures = true;
ListIterator<TabClosureEvent> events = mTabClosureEvents.listIterator();
while (events.hasNext()) {
TabClosureEvent event = events.next();
events.remove();
// This calls notifyOnFinishingMultipleTabClosure once per TabClosureEvent. This is
// intended so that tabs closed as distinct events are recorded as such.
commitClosuresInternal(event.getList());
}
mIsCommittingAllTabClosures = false;
assert mTabClosureEvents.isEmpty();
assert !mRewoundList.hasPendingClosures();
}
/**
* Reverses the most recent {@link #addTabClosureEvent(List<Tab>)} call that hasn't been fully
* committed or fully cancelled.
* Caveats:
* - If any tab closures were cancelled and are in the most recent {@link TabClosureEvent}
* those tabs will not be opened again.
* - If any tab closure were marked as ready to commit but the associated
* {@link TabClosureEvent} has not committed then all tab closures for that event will be
* opened as the assumption is the most recent close event was desired to be undone.
*/
boolean openMostRecentlyClosedEntry() {
mThreadChecker.assertOnValidThread();
assert !mIsCommittingAllTabClosures
: "Modifying mTabClosureEvents while committing all tab closures.";
if (mTabClosureEvents.isEmpty()) return false;
TabClosureEvent event = mTabClosureEvents.removeLast();
for (Tab tab : event.getList()) {
cancelClosureInternal(tab);
}
return true;
}
private void commitClosuresInternal(List<Tab> tabs) {
// Remove tabs first to prevent additional commit attempts in response to closing e.g.
// UndoBarController when dismissing snackbars. This avoids re-entrancy issues when
// closing all due to checks at commitTabClosure. This requires all accesses are on the UI
// thread, but this is already a requirement of TabModelImpl.
for (Tab tab : tabs) {
boolean removed = mRewoundList.removeTab(tab);
// Tabs shouldn't be removed more than once.
assert removed;
}
mDelegate.notifyOnFinishingMultipleTabClosure(tabs);
for (Tab tab : tabs) {
mDelegate.finalizeClosure(tab);
}
}
private void cancelClosureInternal(Tab tab) {
tab.setClosing(false);
// Find a valid previous tab entry so we know what tab to insert after. With the following
// example, calling cancelTabClosure(4) would need to know to insert after 2. So we have to
// track across mRewoundTabs and mTabModel and see what the last valid mTabModel entry was
// (2) when we hit the 4 in the rewound list. An insertIndex of -1 represents the beginning
// of the list, as this is the index of tab to insert after.
// mTabModel: 0 2 5
// mRewoundTabs 0 1 2 3 4 5
int prevIndex = -1;
final int stopIndex = mRewoundList.indexOf(tab);
for (int rewoundIndex = 0; rewoundIndex < stopIndex; rewoundIndex++) {
Tab rewoundTab = mRewoundList.getTabAt(rewoundIndex);
if (prevIndex == mTabModel.getCount() - 1) break;
if (rewoundTab == mTabModel.getTabAt(prevIndex + 1)) prevIndex++;
}
// Figure out where to insert the tab. Just add one to prevIndex, as -1 represents the
// beginning of the list, so we'll insert at 0.
int insertIndex = prevIndex + 1;
mDelegate.insertUndoneTabClosureAt(tab, insertIndex);
}
}