// Copyright 2015 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.tabmodel; import org.chromium.base.ContextUtils; import org.chromium.base.ObserverList; import org.chromium.base.TraceEvent; import org.chromium.chrome.browser.ChromeTabbedActivity; import org.chromium.chrome.browser.compositor.layouts.content.TabContentManager; import org.chromium.chrome.browser.ntp.RecentlyClosedBridge; import org.chromium.chrome.browser.partnercustomizations.HomepageManager; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tabmodel.TabCreatorManager.TabCreator; import org.chromium.chrome.browser.util.MathUtils; import org.chromium.content_public.browser.WebContents; import java.util.ArrayList; import java.util.List; /** * This is the implementation of the synchronous {@link TabModel} for the * {@link ChromeTabbedActivity}. */ public class TabModelImpl extends TabModelJniBridge { /** * The application ID used for tabs opened from an application that does not specify an app ID * in its VIEW intent extras. */ public static final String UNKNOWN_APP_ID = "com.google.android.apps.chrome.unknown_app"; /** * The main list of tabs. Note that when this changes, all pending closures must be committed * via {@link #commitAllTabClosures()} as the indices are no longer valid. Also * {@link RewoundList#resetRewoundState()} must be called so that the full model will be up to * date. */ private final List<Tab> mTabs = new ArrayList<Tab>(); private final TabCreator mRegularTabCreator; private final TabCreator mIncognitoTabCreator; private final TabModelSelectorUma mUma; private final TabModelOrderController mOrderController; private final TabContentManager mTabContentManager; private final TabPersistentStore mTabSaver; private final TabModelDelegate mModelDelegate; private final ObserverList<TabModelObserver> mObservers; private RecentlyClosedBridge mRecentlyClosedBridge; // Undo State Tracking ------------------------------------------------------------------------- /** * 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(); /** * This specifies the current {@link Tab} in {@link #mTabs}. */ private int mIndex = INVALID_TAB_INDEX; /** * Whether this tab model supports undoing. */ private boolean mIsUndoSupported = true; public TabModelImpl(boolean incognito, TabCreator regularTabCreator, TabCreator incognitoTabCreator, TabModelSelectorUma uma, TabModelOrderController orderController, TabContentManager tabContentManager, TabPersistentStore tabSaver, TabModelDelegate modelDelegate, boolean supportUndo) { super(incognito); initializeNative(); mRegularTabCreator = regularTabCreator; mIncognitoTabCreator = incognitoTabCreator; mUma = uma; mOrderController = orderController; mTabContentManager = tabContentManager; mTabSaver = tabSaver; mModelDelegate = modelDelegate; mIsUndoSupported = supportUndo; mObservers = new ObserverList<TabModelObserver>(); mRecentlyClosedBridge = new RecentlyClosedBridge(getProfile()); } @Override public void removeTab(Tab tab) { removeTabAndSelectNext(tab, TabSelectionType.FROM_CLOSE, false, true); for (TabModelObserver obs : mObservers) obs.tabRemoved(tab); } @Override public void destroy() { for (Tab tab : mTabs) { if (tab.isInitialized()) tab.destroy(); } mRewoundList.destroy(); mTabs.clear(); mObservers.clear(); mRecentlyClosedBridge.destroy(); super.destroy(); } @Override public void addObserver(TabModelObserver observer) { mObservers.addObserver(observer); } @Override public void removeObserver(TabModelObserver observer) { mObservers.removeObserver(observer); } /** * Initializes the newly created tab, adds it to controller, and dispatches creation * step notifications. */ @Override public void addTab(Tab tab, int index, TabLaunchType type) { try { TraceEvent.begin("TabModelImpl.addTab"); for (TabModelObserver obs : mObservers) obs.willAddTab(tab, type); boolean selectTab = mOrderController.willOpenInForeground(type, isIncognito()); index = mOrderController.determineInsertionIndex(type, index, tab); assert index <= mTabs.size(); if (tab.isIncognito() != isIncognito()) { throw new IllegalStateException("Attempting to open tab in wrong model"); } // TODO(dtrainor): Update the list of undoable tabs instead of committing it. commitAllTabClosures(); if (index < 0 || index > mTabs.size()) { mTabs.add(tab); } else { mTabs.add(index, tab); if (index <= mIndex) { mIndex++; } } if (!isCurrentModel()) { // When adding new tabs in the background, make sure we set a valid index when the // first one is added. When in the foreground, calls to setIndex will take care of // this. mIndex = Math.max(mIndex, 0); } mRewoundList.resetRewoundState(); int newIndex = indexOf(tab); tabAddedToModel(tab); for (TabModelObserver obs : mObservers) obs.didAddTab(tab, type); if (selectTab) { mModelDelegate.selectModel(isIncognito()); setIndex(newIndex, TabModel.TabSelectionType.FROM_NEW); } } finally { TraceEvent.end("TabModelImpl.addTab"); } } @Override public void moveTab(int id, int newIndex) { newIndex = MathUtils.clamp(newIndex, 0, mTabs.size()); int curIndex = TabModelUtils.getTabIndexById(this, id); if (curIndex == INVALID_TAB_INDEX || curIndex == newIndex || curIndex + 1 == newIndex) { return; } // TODO(dtrainor): Update the list of undoable tabs instead of committing it. commitAllTabClosures(); Tab tab = mTabs.remove(curIndex); if (curIndex < newIndex) --newIndex; mTabs.add(newIndex, tab); if (curIndex == mIndex) { mIndex = newIndex; } else if (curIndex < mIndex && newIndex >= mIndex) { --mIndex; } else if (curIndex > mIndex && newIndex <= mIndex) { ++mIndex; } mRewoundList.resetRewoundState(); for (TabModelObserver obs : mObservers) obs.didMoveTab(tab, newIndex, curIndex); } @Override public boolean closeTab(Tab tab) { return closeTab(tab, true, false, false); } private Tab findTabInAllTabModels(int tabId) { Tab tab = TabModelUtils.getTabById(mModelDelegate.getModel(isIncognito()), tabId); if (tab != null) return tab; return TabModelUtils.getTabById(mModelDelegate.getModel(!isIncognito()), tabId); } @Override public Tab getNextTabIfClosed(int id) { Tab tabToClose = TabModelUtils.getTabById(this, id); Tab currentTab = TabModelUtils.getCurrentTab(this); if (tabToClose == null) return currentTab; int closingTabIndex = indexOf(tabToClose); Tab adjacentTab = getTabAt((closingTabIndex == 0) ? 1 : closingTabIndex - 1); Tab parentTab = findTabInAllTabModels(tabToClose.getParentId()); // Determine which tab to select next according to these rules: // * If closing a background tab, keep the current tab selected. // * Otherwise, if not in overview mode, select the parent tab if it exists. // * Otherwise, select an adjacent tab if one exists. // * Otherwise, if closing the last incognito tab, select the current normal tab. // * Otherwise, select nothing. Tab nextTab = null; if (tabToClose != currentTab && currentTab != null && !currentTab.isClosing()) { nextTab = currentTab; } else if (parentTab != null && !parentTab.isClosing() && !mModelDelegate.isInOverviewMode()) { nextTab = parentTab; } else if (adjacentTab != null && !adjacentTab.isClosing()) { nextTab = adjacentTab; } else if (isIncognito()) { nextTab = TabModelUtils.getCurrentTab(mModelDelegate.getModel(false)); if (nextTab != null && nextTab.isClosing()) nextTab = null; } return nextTab; } @Override public boolean isClosurePending(int tabId) { return mRewoundList.getPendingRewindTab(tabId) != null; } @Override public boolean supportsPendingClosures() { return !isIncognito() && mIsUndoSupported; } @Override public TabList getComprehensiveModel() { if (!supportsPendingClosures()) return this; return mRewoundList; } @Override public void cancelTabClosure(int tabId) { Tab tab = mRewoundList.getPendingRewindTab(tabId); if (tab == null) return; 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 mTabs and see what the last valid mTabs 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. // mTabs: 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 == mTabs.size() - 1) break; if (rewoundTab == mTabs.get(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; if (mIndex >= insertIndex) mIndex++; mTabs.add(insertIndex, tab); WebContents webContents = tab.getWebContents(); if (webContents != null) webContents.setAudioMuted(false); boolean activeModel = mModelDelegate.getCurrentModel() == this; if (mIndex == INVALID_TAB_INDEX) { // If we're the active model call setIndex to actually select this tab, otherwise just // set mIndex but don't kick off everything that happens when calling setIndex(). if (activeModel) { TabModelUtils.setIndex(this, insertIndex); } else { mIndex = insertIndex; } } // Re-save the tab list now that it is being kept. mTabSaver.saveTabListAsynchronously(); for (TabModelObserver obs : mObservers) obs.tabClosureUndone(tab); } @Override public void commitTabClosure(int tabId) { Tab tab = mRewoundList.getPendingRewindTab(tabId); if (tab == null) return; // We're committing the close, actually remove it from the lists and finalize the closing // operation. mRewoundList.removeTab(tab); finalizeTabClosure(tab); for (TabModelObserver obs : mObservers) obs.tabClosureCommitted(tab); } @Override public void commitAllTabClosures() { while (mRewoundList.getCount() > mTabs.size()) { commitTabClosure(mRewoundList.getNextRewindableTab().getId()); } assert !mRewoundList.hasPendingClosures(); if (supportsPendingClosures()) { for (TabModelObserver obs : mObservers) obs.allTabsClosureCommitted(); } } @Override public boolean closeTab(Tab tabToClose, boolean animate, boolean uponExit, boolean canUndo) { return closeTab(tabToClose, animate, uponExit, canUndo, canUndo); } /** * See TabModel.java documentation for description of other parameters. * @param notify Whether or not to notify observers about the pending closure. If this is * {@code true}, {@link #supportsPendingClosures()} is {@code true}, * and canUndo is {@code true}, observers will be notified of the pending * closure. Observers will still be notified of a committed/cancelled closure * even if they are not notified of a pending closure to start with. */ private boolean closeTab(Tab tabToClose, boolean animate, boolean uponExit, boolean canUndo, boolean notify) { if (tabToClose == null) { assert false : "Tab is null!"; return false; } if (!mTabs.contains(tabToClose)) { assert false : "Tried to close a tab from another model!"; return false; } canUndo &= supportsPendingClosures(); startTabClosure(tabToClose, animate, uponExit, canUndo); if (notify && canUndo) { for (TabModelObserver obs : mObservers) obs.tabPendingClosure(tabToClose); } if (!canUndo) finalizeTabClosure(tabToClose); return true; } @Override public void closeAllTabs() { closeAllTabs(true, false); } @Override public void closeAllTabs(boolean allowDelegation, boolean uponExit) { mTabSaver.cancelLoadingTabs(isIncognito()); if (uponExit) { commitAllTabClosures(); for (int i = 0; i < getCount(); i++) getTabAt(i).setClosing(true); while (getCount() > 0) TabModelUtils.closeTabByIndex(this, 0); return; } if (allowDelegation && mModelDelegate.closeAllTabsRequest(isIncognito())) return; if (HomepageManager.isHomepageEnabled(ContextUtils.getApplicationContext())) { commitAllTabClosures(); for (int i = 0; i < getCount(); i++) getTabAt(i).setClosing(true); while (getCount() > 0) TabModelUtils.closeTabByIndex(this, 0); return; } if (getCount() == 1) { closeTab(getTabAt(0), true, false, true); return; } closeAllTabs(true, false, true); } /** * Close all tabs on this model without notifying observers about pending tab closures. * * @param animate true iff the closing animation should be displayed * @param uponExit true iff the tabs are being closed upon application exit (after user presses * the system back button) * @param canUndo Whether or not this action can be undone. If this is {@code true} and * {@link #supportsPendingClosures()} is {@code true}, these {@link Tab}s * will not actually be closed until {@link #commitTabClosure(int)} or * {@link #commitAllTabClosures()} is called, but they will be effectively * removed from this list. */ public void closeAllTabs(boolean animate, boolean uponExit, boolean canUndo) { for (int i = 0; i < getCount(); i++) getTabAt(i).setClosing(true); ArrayList<Integer> closedTabs = new ArrayList<Integer>(); while (getCount() > 0) { Tab tab = getTabAt(0); closedTabs.add(tab.getId()); closeTab(tab, animate, uponExit, canUndo, false); } if (!uponExit && canUndo && supportsPendingClosures()) { for (TabModelObserver obs : mObservers) obs.allTabsPendingClosure(closedTabs); } } @Override public Tab getTabAt(int index) { // This will catch INVALID_TAB_INDEX and return null if (index < 0 || index >= mTabs.size()) return null; return mTabs.get(index); } // Index of the given tab in the order of the tab stack. @Override public int indexOf(Tab tab) { return mTabs.indexOf(tab); } /** * @return true if this is the current model according to the model selector */ private boolean isCurrentModel() { return mModelDelegate.getCurrentModel() == this; } // TODO(aurimas): Move this method to TabModelSelector when notifications move there. private int getLastId(TabSelectionType type) { if (type == TabSelectionType.FROM_CLOSE || type == TabSelectionType.FROM_EXIT) { return Tab.INVALID_TAB_ID; } // Get the current tab in the current tab model. Tab currentTab = TabModelUtils.getCurrentTab(mModelDelegate.getCurrentModel()); return currentTab != null ? currentTab.getId() : Tab.INVALID_TAB_ID; } private boolean hasValidTab() { if (mTabs.size() <= 0) return false; for (int i = 0; i < mTabs.size(); i++) { if (!mTabs.get(i).isClosing()) return true; } return false; } // This function is complex and its behavior depends on persisted state, including mIndex. @Override public void setIndex(int i, final TabSelectionType type) { try { TraceEvent.begin("TabModelImpl.setIndex"); int lastId = getLastId(type); if (!isCurrentModel()) { mModelDelegate.selectModel(isIncognito()); } if (!hasValidTab()) { mIndex = INVALID_TAB_INDEX; } else { mIndex = MathUtils.clamp(i, 0, mTabs.size() - 1); } Tab tab = TabModelUtils.getCurrentTab(this); mModelDelegate.requestToShowTab(tab, type); if (tab != null) { for (TabModelObserver obs : mObservers) obs.didSelectTab(tab, type, lastId); boolean wasAlreadySelected = tab.getId() == lastId; if (!wasAlreadySelected && type == TabSelectionType.FROM_USER && mUma != null) { // We only want to record when the user actively switches to a different tab. mUma.userSwitchedToTab(); } } } finally { TraceEvent.end("TabModelImpl.setIndex"); } } /** * Performs the necessary actions to remove this {@link Tab} from this {@link TabModel}. * This does not actually destroy the {@link Tab} (see * {@link #finalizeTabClosure(Tab)}. * * @param tab The {@link Tab} to remove from this {@link TabModel}. * @param animate Whether or not to animate the closing. * @param uponExit Whether or not this is closing while the Activity is exiting. * @param canUndo Whether or not this operation can be undone. Note that if this is {@code true} * and {@link #supportsPendingClosures()} is {@code true}, * {@link #commitTabClosure(int)} or {@link #commitAllTabClosures()} needs to be * called to actually delete and clean up {@code tab}. */ private void startTabClosure(Tab tab, boolean animate, boolean uponExit, boolean canUndo) { tab.setClosing(true); for (TabModelObserver obs : mObservers) obs.willCloseTab(tab, animate); TabSelectionType selectionType = uponExit ? TabSelectionType.FROM_EXIT : TabSelectionType.FROM_CLOSE; boolean pauseMedia = canUndo; boolean updateRewoundList = !canUndo; removeTabAndSelectNext(tab, selectionType, pauseMedia, updateRewoundList); } /** * Removes the given tab from the tab model and selects a new tab. */ private void removeTabAndSelectNext(Tab tab, TabSelectionType selectionType, boolean pauseMedia, boolean updateRewoundList) { assert selectionType == TabSelectionType.FROM_CLOSE || selectionType == TabSelectionType.FROM_EXIT; final int closingTabId = tab.getId(); final int closingTabIndex = indexOf(tab); Tab currentTab = TabModelUtils.getCurrentTab(this); Tab adjacentTab = getTabAt(closingTabIndex == 0 ? 1 : closingTabIndex - 1); Tab nextTab = getNextTabIfClosed(closingTabId); // TODO(dtrainor): Update the list of undoable tabs instead of committing it. if (updateRewoundList) commitAllTabClosures(); // Cancel or mute any media currently playing. if (pauseMedia) { WebContents webContents = tab.getWebContents(); if (webContents != null) { webContents.suspendAllMediaPlayers(); webContents.setAudioMuted(true); } } mTabs.remove(tab); boolean nextIsIncognito = nextTab == null ? false : nextTab.isIncognito(); int nextTabId = nextTab == null ? Tab.INVALID_TAB_ID : nextTab.getId(); int nextTabIndex = nextTab == null ? INVALID_TAB_INDEX : TabModelUtils.getTabIndexById( mModelDelegate.getModel(nextIsIncognito), nextTabId); if (nextTab != currentTab) { if (nextIsIncognito != isIncognito()) mIndex = indexOf(adjacentTab); TabModel nextModel = mModelDelegate.getModel(nextIsIncognito); nextModel.setIndex(nextTabIndex, selectionType); } else { mIndex = nextTabIndex; } if (updateRewoundList) mRewoundList.resetRewoundState(); } /** * Actually closes and cleans up {@code tab}. * @param tab The {@link Tab} to close. */ private void finalizeTabClosure(Tab tab) { if (mTabContentManager != null) mTabContentManager.removeTabThumbnail(tab.getId()); mTabSaver.removeTabFromQueues(tab); if (!isIncognito()) tab.createHistoricalTab(); tab.destroy(); for (TabModelObserver obs : mObservers) obs.didCloseTab(tab.getId(), tab.isIncognito()); } 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<Tab>(); @Override public boolean isIncognito() { return TabModelImpl.this.isIncognito(); } /** * If {@link TabModel} has a valid selected tab, this will return that same tab in the * context of the rewound list of tabs. If {@link TabModel} has no tabs but the rewound * list is not empty, it will return 0, the first tab. Otherwise it will return * {@link TabModel#INVALID_TAB_INDEX}. * @return The selected index of the rewound list of tabs (includes all pending closures). */ @Override public int index() { if (TabModelImpl.this.index() != INVALID_TAB_INDEX) { return mRewoundTabs.indexOf(TabModelUtils.getCurrentTab(TabModelImpl.this)); } 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); } @Override public boolean isClosurePending(int tabId) { return TabModelImpl.this.isClosurePending(tabId); } /** * Resets this list to match the original {@link TabModel}. Note that if the * {@link TabModel} doesn't support pending closures this model will be empty. This should * be called whenever {@link #mTabs} changes. */ public void resetRewoundState() { mRewoundTabs.clear(); if (TabModelImpl.this.supportsPendingClosures()) { for (int i = 0; i < TabModelImpl.this.getCount(); i++) { mRewoundTabs.add(TabModelImpl.this.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 {@link #mTabs}. * * @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 {@link #mTabs}. {@code null} otherwise. */ public Tab getPendingRewindTab(int tabId) { if (!TabModelImpl.this.supportsPendingClosures()) return null; if (TabModelUtils.getTabById(TabModelImpl.this, tabId) != null) return null; return TabModelUtils.getTabById(this, tabId); } /** * A utility method for easily finding a {@link Tab} that can be closed. * @return The next tab that is in the middle of being closed. */ public Tab getNextRewindableTab() { if (!hasPendingClosures()) return null; for (int i = 0; i < mRewoundTabs.size(); i++) { Tab tab = i < TabModelImpl.this.getCount() ? TabModelImpl.this.getTabAt(i) : null; Tab rewoundTab = mRewoundTabs.get(i); if (tab == null || rewoundTab.getId() != tab.getId()) return rewoundTab; } return null; } /** * Removes a {@link Tab} from this internal list. * @param tab The {@link Tab} to remove. */ public void removeTab(Tab tab) { 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() { for (Tab tab : mRewoundTabs) { if (tab.isInitialized()) tab.destroy(); } } public boolean hasPendingClosures() { return TabModelImpl.this.supportsPendingClosures() && mRewoundTabs.size() > TabModelImpl.this.getCount(); } } @Override protected boolean closeTabAt(int index) { return closeTab(getTabAt(index)); } @Override protected TabCreator getTabCreator(boolean incognito) { return incognito ? mIncognitoTabCreator : mRegularTabCreator; } @Override protected boolean createTabWithWebContents(Tab parent, boolean incognito, WebContents webContents, int parentId) { return getTabCreator(incognito).createTabWithWebContents(parent, webContents, parentId, TabLaunchType.FROM_LONGPRESS_BACKGROUND); } @Override public int getCount() { return mTabs.size(); } @Override public int index() { return mIndex; } @Override protected boolean isSessionRestoreInProgress() { return mModelDelegate.isSessionRestoreInProgress(); } @Override public void openMostRecentlyClosedTab() { // First try to recover tab from rewound list, same as {@link UndoBarController}. if (mRewoundList.hasPendingClosures()) { Tab tab = mRewoundList.getNextRewindableTab(); if (tab == null) return; cancelTabClosure(tab.getId()); return; } // If there are no pending closures in the rewound list, // then try to restore the tab from the native tab restore service. mRecentlyClosedBridge.openRecentlyClosedTab(); // If there is only one tab, select it. if (getCount() == 1) setIndex(0, TabSelectionType.FROM_NEW); } }