chromium/chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentTabsManager.java

// Copyright 2015 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.ntp;

import android.content.Context;

import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.invalidation.SessionsInvalidationManager;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.recent_tabs.ForeignSessionHelper;
import org.chromium.chrome.browser.recent_tabs.ForeignSessionHelper.ForeignSession;
import org.chromium.chrome.browser.recent_tabs.ForeignSessionHelper.ForeignSessionTab;
import org.chromium.chrome.browser.signin.SigninAndHistorySyncActivityLauncherImpl;
import org.chromium.chrome.browser.signin.SyncConsentActivityLauncherImpl;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.ProfileDataCache;
import org.chromium.chrome.browser.signin.services.SigninManager;
import org.chromium.chrome.browser.signin.services.SigninManager.SignInStateObserver;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.ui.favicon.FaviconHelper;
import org.chromium.chrome.browser.ui.favicon.FaviconHelper.FaviconImageCallback;
import org.chromium.chrome.browser.ui.signin.PersonalizedSigninPromoView;
import org.chromium.chrome.browser.ui.signin.SyncPromoController;
import org.chromium.chrome.browser.ui.signin.SyncPromoController.SyncPromoState;
import org.chromium.chrome.browser.ui.signin.account_picker.AccountPickerBottomSheetStrings;
import org.chromium.components.signin.AccountManagerFacadeProvider;
import org.chromium.components.signin.AccountsChangeObserver;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.metrics.SigninAccessPoint;
import org.chromium.components.sync.SyncService;
import org.chromium.url.GURL;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Provides the domain logic and data for RecentTabsPage and RecentTabsRowAdapter. */
public class RecentTabsManager
        implements SyncService.SyncStateChangedListener,
                SignInStateObserver,
                ProfileDataCache.Observer,
                AccountsChangeObserver {
    /** Implement this to receive updates when the page contents change. */
    interface UpdatedCallback {
        /** Called when the list of recently closed tabs or foreign sessions changes. */
        void onUpdated();
    }

    private static final int RECENTLY_CLOSED_MAX_ENTRY_COUNT = 5;

    private static RecentlyClosedTabManager sRecentlyClosedTabManagerForTests;

    private final Profile mProfile;
    private final Tab mActiveTab;
    private final TabModelSelector mTabModelSelector;
    private final Runnable mShowHistoryManager;

    private TabModel mTabModel;
    private @SyncPromoState int mPromoState = SyncPromoState.NO_PROMO;
    private FaviconHelper mFaviconHelper;
    private ForeignSessionHelper mForeignSessionHelper;
    private List<ForeignSession> mForeignSessions;
    private List<RecentlyClosedEntry> mRecentlyClosedEntries;
    private RecentTabsPagePrefs mPrefs;
    private RecentlyClosedTabManager mRecentlyClosedTabManager;
    private SigninManager mSignInManager;
    private UpdatedCallback mUpdatedCallback;
    private boolean mIsDestroyed;

    private final ProfileDataCache mProfileDataCache;
    private final SyncPromoController mSyncPromoController;
    private final SyncService mSyncService;

    /**
     * Maps Session IDs to whether that entry was restored split by entry type. These are used to
     * record historgrams on {@link #destroy()} to measure restore ratio. Cached Session IDs are
     * used to de-duplicate as update would otherwise result in incorrect metrics.
     */
    private final Map<Integer, Boolean> mTabSessionIdsRestored = new HashMap<>();

    private final Map<Integer, Boolean> mGroupSessionIdsRestored = new HashMap<>();
    private final Map<Integer, Boolean> mBulkSessionIdsRestored = new HashMap<>();

    /**
     * Create an RecentTabsManager to be used with RecentTabsPage and RecentTabsRowAdapter.
     *
     * @param tab The Tab that is showing this recent tabs page.
     * @param tabModelSelector The TabModelSelector that contains or will contain {@code tab}.
     * @param profile Profile that is associated with the current session.
     * @param context the Android context this manager will work in.
     * @param showHistoryManager Runnable showing history manager UI.
     */
    public RecentTabsManager(
            Tab tab,
            TabModelSelector tabModelSelector,
            Profile profile,
            Context context,
            Runnable showHistoryManager) {
        mProfile = profile;
        mActiveTab = tab;
        mTabModelSelector = tabModelSelector;
        mShowHistoryManager = showHistoryManager;
        mForeignSessionHelper = new ForeignSessionHelper(profile);
        mPrefs = new RecentTabsPagePrefs(profile);
        mFaviconHelper = new FaviconHelper();
        mRecentlyClosedTabManager =
                sRecentlyClosedTabManagerForTests != null
                        ? sRecentlyClosedTabManagerForTests
                        : new RecentlyClosedBridge(profile, tabModelSelector);
        mSignInManager = IdentityServicesProvider.get().getSigninManager(mProfile);

        mProfileDataCache = ProfileDataCache.createWithDefaultImageSizeAndNoBadge(context);
        AccountPickerBottomSheetStrings bottomSheetStrings =
                new AccountPickerBottomSheetStrings.Builder(
                                R.string.signin_account_picker_bottom_sheet_title)
                        .build();
        mSyncPromoController =
                new SyncPromoController(
                        mProfile,
                        bottomSheetStrings,
                        SigninAccessPoint.RECENT_TABS,
                        SyncConsentActivityLauncherImpl.get(),
                        SigninAndHistorySyncActivityLauncherImpl.get());
        mSyncService = SyncServiceFactory.getForProfile(mProfile);

        mRecentlyClosedTabManager.setEntriesUpdatedRunnable(this::updateRecentlyClosedEntries);
        updateRecentlyClosedEntries();

        mForeignSessionHelper.setOnForeignSessionCallback(this::updateForeignSessions);
        updateForeignSessions();
        mForeignSessionHelper.triggerSessionSync();

        mSyncService.addSyncStateChangedListener(this);
        mSignInManager.addSignInStateObserver(this);
        mProfileDataCache.addObserver(this);
        AccountManagerFacadeProvider.getInstance().addObserver(this);
        updatePromoState();

        SessionsInvalidationManager.get(mProfile).onRecentTabsPageOpened();
    }

    /** Return the {@link Profile} associated with the recent tabs. */
    public Profile getProfile() {
        return mProfile;
    }

    private static int countSessionIdsRestored(Map<Integer, Boolean> sessionIdToRestoredState) {
        int count = 0;
        for (Boolean state : sessionIdToRestoredState.values()) {
            count += (state) ? 1 : 0;
        }
        return count;
    }

    private static void recordEntries(
            String entryType, Map<Integer, Boolean> sessionIdToRestoredState) {
        final int shownCount = sessionIdToRestoredState.size();
        RecordHistogram.recordCount1000Histogram(
                "Tabs.RecentlyClosed.EntriesShownInPage." + entryType, shownCount);
        if (shownCount > 0) {
            final int restoredCount = countSessionIdsRestored(sessionIdToRestoredState);
            RecordHistogram.recordCount1000Histogram(
                    "Tabs.RecentlyClosed.EntriesRestoredInPage." + entryType, restoredCount);
            final int percentRestored = Math.round((restoredCount * 100.0f) / shownCount);
            RecordHistogram.recordPercentageHistogram(
                    "Tabs.RecentlyClosed.PercentOfEntriesRestoredInPage." + entryType,
                    percentRestored);
        }
    }

    /**
     * Should be called when this object is no longer needed. Performs necessary listener tear down.
     */
    public void destroy() {
        mIsDestroyed = true;

        recordEntries("Tab", mTabSessionIdsRestored);
        recordEntries("Group", mGroupSessionIdsRestored);
        recordEntries("Bulk", mBulkSessionIdsRestored);

        mSyncService.removeSyncStateChangedListener(this);

        mSignInManager.removeSignInStateObserver(this);
        mSignInManager = null;

        mProfileDataCache.removeObserver(this);
        AccountManagerFacadeProvider.getInstance().removeObserver(this);

        mFaviconHelper.destroy();
        mFaviconHelper = null;

        mRecentlyClosedTabManager.destroy();
        mRecentlyClosedTabManager = null;

        mUpdatedCallback = null;

        mPrefs.destroy();
        mPrefs = null;

        SessionsInvalidationManager.get(mProfile).onRecentTabsPageClosed();

        mForeignSessionHelper.destroy();
        mForeignSessionHelper = null;
    }

    private void updateRecentlyClosedEntries() {
        mRecentlyClosedEntries =
                mRecentlyClosedTabManager.getRecentlyClosedEntries(RECENTLY_CLOSED_MAX_ENTRY_COUNT);
        for (RecentlyClosedEntry entry : mRecentlyClosedEntries) {
            if (entry instanceof RecentlyClosedTab
                    && !mTabSessionIdsRestored.containsKey(entry.getSessionId())) {
                mTabSessionIdsRestored.put(entry.getSessionId(), false);
            } else if (entry instanceof RecentlyClosedGroup
                    && !mGroupSessionIdsRestored.containsKey(entry.getSessionId())) {
                mGroupSessionIdsRestored.put(entry.getSessionId(), false);
            } else if (entry instanceof RecentlyClosedBulkEvent
                    && !mBulkSessionIdsRestored.containsKey(entry.getSessionId())) {
                mBulkSessionIdsRestored.put(entry.getSessionId(), false);
            }
        }
        onUpdateDone();
    }

    private void updateForeignSessions() {
        mForeignSessions = mForeignSessionHelper.getForeignSessions();
        onUpdateDone();
    }

    /**
     * @return Most up-to-date list of foreign sessions.
     */
    public List<ForeignSession> getForeignSessions() {
        return mForeignSessions;
    }

    /**
     * @return Most up-to-date list of recently closed tabs.
     */
    public List<RecentlyClosedEntry> getRecentlyClosedEntries() {
        return mRecentlyClosedEntries;
    }

    /**
     * Opens a new tab navigating to ForeignSessionTab.
     *
     * @param session The foreign session that the tab belongs to.
     * @param tab The tab to open.
     * @param windowDisposition The WindowOpenDisposition flag.
     */
    public void openForeignSessionTab(
            ForeignSession session, ForeignSessionTab tab, int windowDisposition) {
        if (mIsDestroyed) return;
        RecordUserAction.record("MobileRecentTabManagerTabFromOtherDeviceOpened");
        RecordUserAction.record("MobileCrossDeviceTabJourney");
        mForeignSessionHelper.openForeignSessionTab(mActiveTab, session, tab, windowDisposition);
    }

    /**
     * Restores a recently closed tab.
     *
     * @param tab The tab to open.
     * @param windowDisposition The WindowOpenDisposition value specifying whether the tab should
     *         be restored into the current tab or a new tab.
     */
    public void openRecentlyClosedTab(RecentlyClosedTab tab, int windowDisposition) {
        if (mIsDestroyed) return;
        mTabSessionIdsRestored.put(tab.getSessionId(), true);
        RecordUserAction.record("MobileRecentTabManagerRecentTabOpened");
        // Window disposition will select which tab to open.
        mRecentlyClosedTabManager.openRecentlyClosedTab(getTabModel(), tab, windowDisposition);
    }

    /**
     * Restores a recently closed entry. Use {@link #openRecentlyClosedTab()} for single tabs..
     *
     * @param entry The entry to open.
     */
    public void openRecentlyClosedEntry(RecentlyClosedEntry entry) {
        if (mIsDestroyed) return;

        assert !(entry instanceof RecentlyClosedTab)
                : "Opening a RecentlyClosedTab should use openRecentlyClosedTab().";

        if (entry instanceof RecentlyClosedGroup) {
            mGroupSessionIdsRestored.put(entry.getSessionId(), true);
            RecordUserAction.record("MobileRecentTabManagerRecentGroupOpened");
        } else if (entry instanceof RecentlyClosedBulkEvent) {
            mBulkSessionIdsRestored.put(entry.getSessionId(), true);
            RecordUserAction.record("MobileRecentTabManagerRecentBulkEventOpened");
        }
        mRecentlyClosedTabManager.openRecentlyClosedEntry(getTabModel(), entry);
    }

    /** Opens the history page. */
    public void openHistoryPage() {
        if (mIsDestroyed) return;
        mShowHistoryManager.run();
    }

    /**
     * Return the managed tab.
     * @return the tab instance being managed by this object.
     */
    public Tab activeTab() {
        return mActiveTab;
    }

    /**
     * Returns a favicon for a given foreign url.
     *
     * @param url The url to fetch the favicon for.
     * @param size the desired favicon size.
     * @param faviconCallback the callback to be invoked when the favicon is available.
     * @return favicon or null if favicon unavailable.
     */
    public boolean getForeignFaviconForUrl(
            GURL url, int size, FaviconImageCallback faviconCallback) {
        return mFaviconHelper.getForeignFaviconImageForURL(mProfile, url, size, faviconCallback);
    }

    /**
     * Fetches a favicon for snapshot document url which is returned via callback.
     *
     * @param url The url to fetch a favicon for.
     * @param size the desired favicon size.
     * @param faviconCallback the callback to be invoked when the favicon is available.
     *
     * @return may return false if we could not fetch the favicon.
     */
    public boolean getLocalFaviconForUrl(GURL url, int size, FaviconImageCallback faviconCallback) {
        return mFaviconHelper.getLocalFaviconImageForURL(mProfile, url, size, faviconCallback);
    }

    /**
     * Sets a callback to be invoked when recently closed tabs or foreign sessions documents have
     * been updated.
     *
     * @param updatedCallback the listener to be invoked.
     */
    public void setUpdatedCallback(UpdatedCallback updatedCallback) {
        mUpdatedCallback = updatedCallback;
    }

    /**
     * Sets the persistent expanded/collapsed state of a foreign session list.
     *
     * @param session foreign session to collapsed.
     * @param isCollapsed Whether the session is collapsed or expanded.
     */
    public void setForeignSessionCollapsed(ForeignSession session, boolean isCollapsed) {
        if (mIsDestroyed) return;
        mPrefs.setForeignSessionCollapsed(session, isCollapsed);
    }

    /**
     * Determine the expanded/collapsed state of a foreign session list.
     *
     * @param session foreign session whose state to obtain.
     *
     * @return Whether the session is collapsed.
     */
    public boolean getForeignSessionCollapsed(ForeignSession session) {
        return mPrefs.getForeignSessionCollapsed(session);
    }

    /**
     * Sets the persistent expanded/collapsed state of the recently closed tabs list.
     *
     * @param isCollapsed Whether the recently closed tabs list is collapsed.
     */
    public void setRecentlyClosedTabsCollapsed(boolean isCollapsed) {
        if (mIsDestroyed) return;
        mPrefs.setRecentlyClosedTabsCollapsed(isCollapsed);
    }

    /**
     * Determine the expanded/collapsed state of the recently closed tabs list.
     *
     * @return Whether the recently closed tabs list is collapsed.
     */
    public boolean isRecentlyClosedTabsCollapsed() {
        return mPrefs.getRecentlyClosedTabsCollapsed();
    }

    /**
     * Remove Foreign session to display. Note that it might reappear during the next sync if the
     * session is not orphaned.
     *
     * This is mainly for when user wants to delete an orphaned session.
     * @param session Session to be deleted.
     */
    public void deleteForeignSession(ForeignSession session) {
        if (mIsDestroyed) return;
        mForeignSessionHelper.deleteForeignSession(session);
    }

    /** Clears the list of recently closed tabs. */
    public void clearRecentlyClosedEntries() {
        if (mIsDestroyed) return;
        RecordUserAction.record("MobileRecentTabManagerRecentTabsCleared");
        mRecentlyClosedTabManager.clearRecentlyClosedEntries();
    }

    /**
     * Collapse the promo.
     *
     * @param isCollapsed Whether the promo is collapsed.
     */
    public void setPromoCollapsed(boolean isCollapsed) {
        if (mIsDestroyed) return;
        mPrefs.setSyncPromoCollapsed(isCollapsed);
    }

    /**
     * Determine whether the promo is collapsed.
     *
     * @return Whether the promo is collapsed.
     */
    public boolean isPromoCollapsed() {
        return mPrefs.getSyncPromoCollapsed();
    }

    /** Returns the current promo state. */
    @SyncPromoState
    int getPromoState() {
        return mPromoState;
    }

    private @SyncPromoState int calculatePromoState() {
        if (ChromeFeatureList.isEnabled(
                ChromeFeatureList.REPLACE_SYNC_PROMOS_WITH_SIGN_IN_PROMOS)) {
            // If ReplaceSyncPromosWithSignInPromos is enabled, there's only one promo type.
            //
            // TODO(crbug.com/343908771): Revise SyncPromoState after launching
            //     ReplaceSyncPromosWithSignInPromos.
            if (!mSyncPromoController.canShowSyncPromo()) {
                return SyncPromoState.NO_PROMO;
            }
            return SyncPromoState.PROMO_FOR_SIGNED_OUT_STATE;
        }
        if (!mSignInManager.getIdentityManager().hasPrimaryAccount(ConsentLevel.SYNC)) {
            if (!mSyncPromoController.canShowSyncPromo()) {
                return SyncPromoState.NO_PROMO;
            }
            // TODO(crbug.com/338541375): Move this check inside
            //  SyncPromoController#canShowSyncPromo().
            if (!mSignInManager.isSyncOptInAllowed()) {
                return SyncPromoState.NO_PROMO;
            }
            if (mSignInManager.getIdentityManager().hasPrimaryAccount(ConsentLevel.SIGNIN)) {
                return SyncPromoState.PROMO_FOR_SIGNED_IN_STATE;
            }
            return SyncPromoState.PROMO_FOR_SIGNED_OUT_STATE;
        }

        if (!mForeignSessions.isEmpty()) {
            return SyncPromoState.NO_PROMO;
        }

        // TODO(crbug.com/40850972): PROMO_FOR_SYNC_TURNED_OFF_STATE should only
        // be returned if mSyncService.getSelectedTypes().isEmpty(). Otherwise,
        // LegacySyncPromoView incorrectly displays a promo with string
        // R.string.ntp_recent_tabs_sync_promo_instructions.
        return SyncPromoState.PROMO_FOR_SYNC_TURNED_OFF_STATE;
    }

    private void updatePromoState() {
        final @SyncPromoState int newState = calculatePromoState();
        if (newState == mPromoState) return;

        final boolean hasSyncPromoStateChangedtoShown =
                (mPromoState == SyncPromoState.NO_PROMO
                                || mPromoState == SyncPromoState.PROMO_FOR_SYNC_TURNED_OFF_STATE)
                        && (newState == SyncPromoState.PROMO_FOR_SIGNED_IN_STATE
                                || newState == SyncPromoState.PROMO_FOR_SIGNED_OUT_STATE);
        if (hasSyncPromoStateChangedtoShown) {
            mSyncPromoController.increasePromoShowCount();
        }
        mPromoState = newState;
    }

    /** Sets up the sync promo view. */
    void setUpSyncPromoView(PersonalizedSigninPromoView view) {
        mSyncPromoController.setUpSyncPromoView(mProfileDataCache, view, null);
    }

    // SignInStateObserver implementation.
    @Override
    public void onSignedIn() {
        update();
    }

    @Override
    public void onSignedOut() {
        update();
    }

    // AccountsChangeObserver implementation.
    @Override
    public void onCoreAccountInfosChanged() {
        update();
    }

    // ProfileDataCache.Observer implementation.
    @Override
    public void onProfileDataUpdated(String accountEmail) {
        update();
    }

    // SyncService.SyncStateChangedListener implementation.
    @Override
    public void syncStateChanged() {
        update();
    }

    private void onUpdateDone() {
        if (mUpdatedCallback != null) {
            mUpdatedCallback.onUpdated();
        }
    }

    private void update() {
        updatePromoState();
        if (mIsDestroyed) return;
        updateForeignSessions();
        onUpdateDone();
    }

    private TabModel getTabModel() {
        // When RecentTabsManager is created for a new tab then {@link mActiveTab} is being
        // created and will not be present in a {@link TabModel} of {@link mTabModelSelector}.
        // Defer finding the {@link TabModel} until the first time it is needed after the
        // constructor has finished.
        if (mTabModel != null) return mTabModel;

        mTabModel = mTabModelSelector.getModelForTabId(mActiveTab.getId());
        assert mTabModel != null;
        return mTabModel;
    }

    public static void setRecentlyClosedTabManagerForTests(RecentlyClosedTabManager manager) {
        sRecentlyClosedTabManagerForTests = manager;
        ResettersForTesting.register(() -> sRecentlyClosedTabManagerForTests = null);
    }
}