chromium/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/TabGroupSyncLocalObserver.java

// Copyright 2024 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.tab_group_sync;

import androidx.annotation.Nullable;

import org.chromium.base.Token;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver;
import org.chromium.components.tab_group_sync.LocalTabGroupId;
import org.chromium.components.tab_group_sync.SavedTabGroup;
import org.chromium.components.tab_group_sync.SavedTabGroupTab;
import org.chromium.components.tab_group_sync.TabGroupSyncService;

import java.util.HashSet;
import java.util.List;

/**
 * Responsible for observing local tab model system and notifying sync about tab group changes, tab
 * changes, navigations etc. Also responsible for running the startup routine which 1. Observes the
 * startup completion signals from both local tab model and {@link TabGroupSyncService} and 2.
 * Modifies local tab model to ensure that both local and sync version of tab groups are equivalent.
 */
public final class TabGroupSyncLocalObserver {
    private static final String TAG = "TG.LocalObserver";
    private final TabGroupModelFilter mTabGroupModelFilter;
    private final TabGroupSyncService mTabGroupSyncService;
    private final RemoteTabGroupMutationHelper mRemoteTabGroupMutationHelper;

    private final TabModelObserver mTabModelObserver;
    private final TabGroupModelFilterObserver mTabGroupModelFilterObserver;
    private final NavigationTracker mNavigationTracker;
    private final NavigationObserver mNavigationObserver;
    private final HashSet<Integer> mTabIdsSelectedInSession = new HashSet<>();
    private boolean mIsObserving;

    /**
     * Constructor.
     *
     * @param tabModelSelector The {@link TabModelSelector} to observe for local tab changes.
     * @param tabGroupModelFilter The {@link TabGroupModelFilter} to observe for local tab group
     *     changes.
     * @param tabGroupSyncService The sync backend to be notified of local changes.
     * @param remoteTabGroupMutationHelper Helper class for mutation of sync.
     * @param navigationTracker Tracker tracking navigations initiated by sync.
     */
    public TabGroupSyncLocalObserver(
            TabModelSelector tabModelSelector,
            TabGroupModelFilter tabGroupModelFilter,
            TabGroupSyncService tabGroupSyncService,
            RemoteTabGroupMutationHelper remoteTabGroupMutationHelper,
            NavigationTracker navigationTracker) {
        mTabGroupModelFilter = tabGroupModelFilter;
        mTabGroupSyncService = tabGroupSyncService;
        mRemoteTabGroupMutationHelper = remoteTabGroupMutationHelper;
        mNavigationTracker = navigationTracker;

        // Start observing tab groups and tab model.
        mTabModelObserver = createTabModelObserver();
        mTabGroupModelFilterObserver = createTabGroupModelFilterObserver();
        mTabGroupModelFilter.addObserver(mTabModelObserver);
        mTabGroupModelFilter.addTabGroupObserver(mTabGroupModelFilterObserver);

        // Start observing navigations.
        mNavigationObserver =
                new NavigationObserver(tabModelSelector, mTabGroupSyncService, mNavigationTracker);
    }

    /** Called on destroy. */
    public void destroy() {
        mTabGroupModelFilter.removeTabGroupObserver(mTabGroupModelFilterObserver);
        mTabGroupModelFilter.removeObserver(mTabModelObserver);
    }

    /**
     * Called to enable or disable this observer. When disabled, none of the local changes will not
     * be propagated to sync. Typically invoked when chrome is in the middle of applying remote
     * updates to the local tab model.
     *
     * @param enable Whether to enable the observer.
     */
    public void enableObservers(boolean enable) {
        mIsObserving = enable;
        mNavigationObserver.enableObservers(enable);
    }

    private TabModelObserver createTabModelObserver() {
        return new TabModelObserver() {
            @Override
            public void didAddTab(
                    Tab tab, int type, int creationState, boolean markedForSelection) {
                if (!mIsObserving || tab.getTabGroupId() == null) return;
                LogUtils.log(TAG, "didAddTab");

                mRemoteTabGroupMutationHelper.addTab(
                        TabGroupSyncUtils.getLocalTabGroupId(tab),
                        tab,
                        mTabGroupModelFilter.getIndexOfTabInGroup(tab));
            }

            @Override
            public void onFinishingMultipleTabClosure(List<Tab> tabs, boolean canRestore) {
                if (!mIsObserving || tabs.isEmpty()) return;
                LogUtils.log(TAG, "onFinishingMultipleTabClosure, tabs# " + tabs.size());

                mRemoteTabGroupMutationHelper.handleMultipleTabClosure(tabs);
            }

            // This method is for metrics only!
            @Override
            public void didSelectTab(Tab tab, @TabSelectionType int type, int lastId) {
                if (!mTabGroupModelFilter.isTabInTabGroup(tab)) return;

                if (tab.getTabGroupId() == null) return;

                LocalTabGroupId localId = TabGroupSyncUtils.getLocalTabGroupId(tab);
                SavedTabGroup savedGroup = mTabGroupSyncService.getGroup(localId);
                if (savedGroup == null) return;

                mTabGroupSyncService.onTabSelected(localId, tab.getId());

                if (mTabGroupSyncService.isRemoteDevice(savedGroup.creatorCacheGuid)) {
                    RecordUserAction.record("TabGroups.Sync.SelectedTabInRemotelyCreatedGroup");
                } else {
                    RecordUserAction.record("TabGroups.Sync.SelectedTabInLocallyCreatedGroup");
                }

                SavedTabGroupTab savedTab = getSavedTab(savedGroup, tab.getId());
                boolean tabWasLastUsedRemotely =
                        savedTab != null
                                && mTabGroupSyncService.isRemoteDevice(
                                        savedGroup.lastUpdaterCacheGuid);
                if (tabWasLastUsedRemotely) {
                    int tabId = tab.getId();
                    boolean wasAdded = mTabIdsSelectedInSession.add(tabId);
                    if (wasAdded) {
                        RecordUserAction.record("MobileCrossDeviceTabJourney");
                        RecordUserAction.record(
                                "TabGroups.Sync.SelectedRemotelyUpdatedTabInSession");
                    }
                }
            }
        };
    }

    private TabGroupModelFilterObserver createTabGroupModelFilterObserver() {
        return new TabGroupModelFilterObserver() {
            @Override
            public void didChangeTabGroupColor(int rootId, int newColor) {
                if (!mIsObserving) return;
                LogUtils.log(TAG, "didChangeTabGroupColor, rootId = " + rootId);
                updateVisualData(
                        TabGroupSyncUtils.getLocalTabGroupId(mTabGroupModelFilter, rootId));
            }

            @Override
            public void didChangeTabGroupTitle(int rootId, String newTitle) {
                if (!mIsObserving) return;
                LogUtils.log(TAG, "didChangeTabGroupTitle, rootId = " + rootId);
                updateVisualData(
                        TabGroupSyncUtils.getLocalTabGroupId(mTabGroupModelFilter, rootId));
            }

            @Override
            public void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup) {
                if (!mIsObserving) return;
                LogUtils.log(
                        TAG, "didMergeTabToGroup, selectedTabIdInGroup = " + selectedTabIdInGroup);

                LocalTabGroupId tabGroupRootId =
                        TabGroupSyncUtils.getLocalTabGroupId(
                                mTabGroupModelFilter, movedTab.getRootId());
                if (groupExistsInSync(tabGroupRootId)) {
                    int positionInGroup = mTabGroupModelFilter.getIndexOfTabInGroup(movedTab);
                    mRemoteTabGroupMutationHelper.addTab(tabGroupRootId, movedTab, positionInGroup);
                } else {
                    mRemoteTabGroupMutationHelper.createRemoteTabGroup(tabGroupRootId);
                }
            }

            @Override
            public void didMoveWithinGroup(
                    Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
                if (!mIsObserving) return;
                LogUtils.log(
                        TAG,
                        "didMoveWithinGroup, tabModelOldIndex = "
                                + tabModelOldIndex
                                + ", tabModelNewIndex = "
                                + tabModelNewIndex);

                // The tab position was changed. Update sync.
                int positionInGroup = mTabGroupModelFilter.getIndexOfTabInGroup(movedTab);
                LocalTabGroupId tabGroupId =
                        TabGroupSyncUtils.getLocalTabGroupId(
                                mTabGroupModelFilter, movedTab.getRootId());
                mRemoteTabGroupMutationHelper.moveTab(
                        tabGroupId, movedTab.getId(), positionInGroup);
            }

            @Override
            public void didMoveTabOutOfGroup(Tab movedTab, int prevFilterIndex) {
                if (!mIsObserving) return;
                LogUtils.log(TAG, "didMoveTabOutOfGroup, prevFilterIndex = " + prevFilterIndex);

                // Remove tab from the synced group.
                Tab prevRoot = mTabGroupModelFilter.getTabAt(prevFilterIndex);
                assert prevRoot != null;
                LocalTabGroupId tabGroupId =
                        TabGroupSyncUtils.getLocalTabGroupId(
                                mTabGroupModelFilter, prevRoot.getRootId());
                if (tabGroupId == null) return;
                mRemoteTabGroupMutationHelper.removeTab(tabGroupId, movedTab.getId());
            }

            @Override
            public void didCreateNewGroup(Tab destinationTab, TabGroupModelFilter filter) {
                if (!mIsObserving) return;
                LogUtils.log(TAG, "didCreateNewGroup");
                LocalTabGroupId localTabGroupId =
                        TabGroupSyncUtils.getLocalTabGroupId(
                                mTabGroupModelFilter, destinationTab.getRootId());
                if (groupExistsInSync(localTabGroupId)) return;

                mRemoteTabGroupMutationHelper.createRemoteTabGroup(localTabGroupId);
            }

            @Override
            public void committedTabGroupClosure(Token tabGroupId, boolean wasHiding) {
                StringBuilder builder =
                        new StringBuilder("committedTabGroupClosure, tabGroupId = ")
                                .append(tabGroupId)
                                .append(" wasHiding = ")
                                .append(wasHiding);
                LogUtils.log(TAG, builder.toString());

                mRemoteTabGroupMutationHelper.handleCommittedTabGroupClosure(
                        new LocalTabGroupId(tabGroupId), wasHiding);
            }

            @Override
            public void didRemoveTabGroup(
                    int oldRootId,
                    @Nullable Token oldTabGroupId,
                    @DidRemoveTabGroupReason int removalReason) {
                LogUtils.log(TAG, "didRemoveTabGroup, oldRootId " + oldRootId);
                if (oldTabGroupId == null) return;

                LocalTabGroupId localTabGroupId = new LocalTabGroupId(oldTabGroupId);
                if (removalReason == DidRemoveTabGroupReason.MERGE
                        || removalReason == DidRemoveTabGroupReason.UNGROUP) {
                    mRemoteTabGroupMutationHelper.removeGroup(localTabGroupId);
                }
            }
        };
    }

    private void updateVisualData(LocalTabGroupId tabGroupId) {
        // During group creation from sync, we set the title and color before the group is actually
        // created. Hence, tab group ID could be null.
        if (tabGroupId == null) return;
        mRemoteTabGroupMutationHelper.updateVisualData(tabGroupId);
    }

    private boolean groupExistsInSync(LocalTabGroupId rootId) {
        return mTabGroupSyncService.getGroup(rootId) != null;
    }

    private TabModel getTabModel() {
        return mTabGroupModelFilter.getTabModel();
    }

    private SavedTabGroupTab getSavedTab(SavedTabGroup savedGroup, int tabId) {
        for (SavedTabGroupTab savedTab : savedGroup.savedTabs) {
            if (savedTab.localId != null && savedTab.localId == tabId) return savedTab;
        }
        return null;
    }
}