chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabGroupListMediator.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.tasks.tab_management;

import static org.chromium.chrome.browser.tasks.tab_management.TabGroupRowProperties.DELETE_RUNNABLE;
import static org.chromium.chrome.browser.tasks.tab_management.TabGroupRowProperties.LEAVE_RUNNABLE;

import android.text.TextUtils;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;

import org.chromium.base.CallbackController;
import org.chromium.base.Token;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.bookmarks.PendingRunnable;
import org.chromium.chrome.browser.hub.PaneId;
import org.chromium.chrome.browser.hub.PaneManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab_group_sync.TabGroupUiActionHandler;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabList;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_management.ActionConfirmationManager.ConfirmationResult;
import org.chromium.components.data_sharing.DataSharingService;
import org.chromium.components.data_sharing.DataSharingService.GroupDataOrFailureOutcome;
import org.chromium.components.data_sharing.GroupData;
import org.chromium.components.data_sharing.member_role.MemberRole;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.sync.DataType;
import org.chromium.components.sync.SyncService;
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 org.chromium.components.tab_group_sync.TabGroupSyncService.Observer;
import org.chromium.components.tab_group_sync.TriggerSource;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;

/** Populates a {@link ModelList} with an item for each tab group. */
public class TabGroupListMediator {
    // Internal state enum to track where a group lives. It can either be in the current tab
    // model/window/activity, in the current activity and closing, in another one, or hidden.
    // Hidden means only the sync side know about it. Everything is assumed to be non-incognito.
    // In other tab models is difficult to work with, since often tha tab model is not even
    // loaded into memory. For currently closing groups we need to special case the behavior to
    // properly undo or commit the pending operations.
    @IntDef({
        TabGroupState.IN_CURRENT,
        TabGroupState.IN_CURRENT_CLOSING,
        TabGroupState.IN_ANOTHER,
        TabGroupState.HIDDEN,
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface TabGroupState {
        int IN_CURRENT = 0;
        int IN_CURRENT_CLOSING = 1;
        int IN_ANOTHER = 2;
        int HIDDEN = 3;
    }

    private final ModelList mModelList;
    private final PropertyModel mPropertyModel;
    private final TabGroupModelFilter mFilter;
    private final FaviconResolver mFaviconResolver;
    private final @Nullable TabGroupSyncService mTabGroupSyncService;
    private final @Nullable DataSharingService mDataSharingService;
    private final IdentityManager mIdentityManager;
    private final PaneManager mPaneManager;
    private final TabGroupUiActionHandler mTabGroupUiActionHandler;
    private final ActionConfirmationManager mActionConfirmationManager;
    private final SyncService mSyncService;
    private final CallbackController mCallbackController = new CallbackController();
    private final PendingRunnable mPendingRefresh =
            new PendingRunnable(
                    TaskTraits.UI_DEFAULT,
                    mCallbackController.makeCancelable(this::repopulateModelList));

    private final TabModelObserver mTabModelObserver =
            new TabModelObserver() {
                @Override
                public void tabClosureUndone(Tab tab) {
                    // Sync events aren't sent when a tab closure is undone since sync doesn't know
                    // anything happened until the closure is committed. Make sure the UI is up to
                    // date (with the right TabGroupState) if an undo related to a tab group
                    // happens.
                    if (mFilter.isTabInTabGroup(tab)) {
                        mPendingRefresh.post();
                    }
                }
            };

    private final TabGroupSyncService.Observer mTabGroupSyncObserver =
            new Observer() {
                @Override
                public void onInitialized() {
                    mPendingRefresh.post();
                }

                @Override
                public void onTabGroupAdded(SavedTabGroup group, @TriggerSource int source) {
                    mPendingRefresh.post();
                }

                @Override
                public void onTabGroupUpdated(SavedTabGroup group, @TriggerSource int source) {
                    mPendingRefresh.post();
                }

                @Override
                public void onTabGroupRemoved(LocalTabGroupId localId, @TriggerSource int source) {
                    mPendingRefresh.post();
                }

                @Override
                public void onTabGroupRemoved(String syncId, @TriggerSource int source) {
                    mPendingRefresh.post();
                }

                @Override
                public void onTabGroupLocalIdChanged(
                        String syncTabGroupId, @Nullable LocalTabGroupId localTabGroupId) {
                    mPendingRefresh.post();
                }
            };

    private final SyncService.SyncStateChangedListener mSyncStateChangeListener =
            new SyncService.SyncStateChangedListener() {
                @Override
                public void syncStateChanged() {
                    boolean enabled =
                            mSyncService.getActiveDataTypes().contains(DataType.SAVED_TAB_GROUP);
                    mPropertyModel.set(TabGroupListProperties.SYNC_ENABLED, enabled);
                }
            };

    /**
     * @param modelList Side effect is adding items to this list.
     * @param propertyModel Properties for the empty state.
     * @param filter Used to read current tab groups.
     * @param faviconResolver Used to fetch favicon images for some tabs.
     * @param tabGroupSyncService Used to fetch synced copy of tab groups.
     * @param dataSharingService Used to fetch shared group data.
     * @param identityManager Used to fetch current account information.
     * @param paneManager Used switch panes to show details of a group.
     * @param tabGroupUiActionHandler Used to open hidden tab groups.
     * @param actionConfirmationManager Used to show confirmation dialogs.
     * @param syncService Used to query active sync types.
     */
    public TabGroupListMediator(
            ModelList modelList,
            PropertyModel propertyModel,
            TabGroupModelFilter filter,
            FaviconResolver faviconResolver,
            @Nullable TabGroupSyncService tabGroupSyncService,
            @Nullable DataSharingService dataSharingService,
            IdentityManager identityManager,
            PaneManager paneManager,
            TabGroupUiActionHandler tabGroupUiActionHandler,
            ActionConfirmationManager actionConfirmationManager,
            SyncService syncService) {
        mModelList = modelList;
        mPropertyModel = propertyModel;
        mFilter = filter;
        mFaviconResolver = faviconResolver;
        mTabGroupSyncService = tabGroupSyncService;
        mDataSharingService = dataSharingService;
        mIdentityManager = identityManager;
        mPaneManager = paneManager;
        mTabGroupUiActionHandler = tabGroupUiActionHandler;
        mActionConfirmationManager = actionConfirmationManager;
        mSyncService = syncService;

        mFilter.addObserver(mTabModelObserver);
        if (mTabGroupSyncService != null) {
            mTabGroupSyncService.addObserver(mTabGroupSyncObserver);
        }
        mSyncService.addSyncStateChangedListener(mSyncStateChangeListener);

        repopulateModelList();
        mSyncStateChangeListener.syncStateChanged();
    }

    /** Clean up observers used by this class. */
    public void destroy() {
        mFilter.removeObserver(mTabModelObserver);
        if (mTabGroupSyncService != null) {
            mTabGroupSyncService.removeObserver(mTabGroupSyncObserver);
        }
        mSyncService.removeSyncStateChangedListener(mSyncStateChangeListener);
        mCallbackController.destroy();
    }

    private @TabGroupState int getState(SavedTabGroup savedTabGroup) {
        if (savedTabGroup.localId == null) {
            return TabGroupState.HIDDEN;
        }
        Token groupId = savedTabGroup.localId.tabGroupId;
        boolean isFullyClosing = true;
        int rootId = Tab.INVALID_TAB_ID;
        TabList tabList = mFilter.getTabModel().getComprehensiveModel();
        for (int i = 0; i < tabList.getCount(); i++) {
            Tab tab = tabList.getTabAt(i);
            if (groupId.equals(tab.getTabGroupId())) {
                rootId = tab.getRootId();
                isFullyClosing &= tab.isClosing();
            }
        }
        if (rootId == Tab.INVALID_TAB_ID) return TabGroupState.IN_ANOTHER;

        // If the group is only partially closing no special case is required since we still have to
        // do all the IN_CURRENT work and returning to the tab group via the dialog will work.
        return isFullyClosing ? TabGroupState.IN_CURRENT_CLOSING : TabGroupState.IN_CURRENT;
    }

    private List<SavedTabGroup> getSortedGroupList() {
        List<SavedTabGroup> groupList = new ArrayList<>();
        if (mTabGroupSyncService == null) return groupList;

        for (String syncGroupId : mTabGroupSyncService.getAllGroupIds()) {
            SavedTabGroup savedTabGroup = mTabGroupSyncService.getGroup(syncGroupId);
            assert !savedTabGroup.savedTabs.isEmpty();

            // To simplify interactions, do not include any groups currently open in other windows.
            if (getState(savedTabGroup) != TabGroupState.IN_ANOTHER) {
                groupList.add(savedTabGroup);
            }
        }
        groupList.sort((a, b) -> Long.compare(b.creationTimeMs, a.creationTimeMs));
        return groupList;
    }

    private void repopulateModelList() {
        mModelList.clear();
        @Nullable CoreAccountInfo currentAccountInfo = null;
        for (SavedTabGroup savedTabGroup : getSortedGroupList()) {
            String collaborationId = savedTabGroup.collaborationId;
            boolean isShared = !TextUtils.isEmpty(collaborationId);
            Runnable deleteRunnable = isShared ? null : () -> processDeleteGroup(savedTabGroup);
            PropertyModel model =
                    TabGroupRowMediator.buildModel(
                            savedTabGroup,
                            mFaviconResolver,
                            () -> openGroup(savedTabGroup),
                            deleteRunnable);

            ListItem listItem = new ListItem(0, model);
            mModelList.add(listItem);

            if (isShared) {
                if (currentAccountInfo == null) {
                    currentAccountInfo = getAccountInfo();
                }
                final CoreAccountInfo finalAccountInfo = currentAccountInfo;
                mDataSharingService.readGroup(
                        collaborationId,
                        (GroupDataOrFailureOutcome outcome) ->
                                onGroupDataOrFailureOutcome(outcome, finalAccountInfo, model));
            }
        }

        boolean empty = mModelList.isEmpty();
        mPropertyModel.set(TabGroupListProperties.EMPTY_STATE_VISIBLE, empty);
    }

    private CoreAccountInfo getAccountInfo() {
        return mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
    }

    private void onGroupDataOrFailureOutcome(
            GroupDataOrFailureOutcome outcome, CoreAccountInfo accountInfo, PropertyModel model) {
        @MemberRole
        int memberRole = TabShareUtils.getSelfMemberRole(outcome, accountInfo.getGaiaId());
        @Nullable GroupData groupData = outcome.groupData;
        if (memberRole == MemberRole.OWNER) {
            model.set(
                    DELETE_RUNNABLE,
                    () ->
                            processDeleteSharedGroup(
                                    groupData.displayName, groupData.groupToken.groupId));
        } else {
            model.set(
                    LEAVE_RUNNABLE,
                    () ->
                            processLeaveGroup(
                                    groupData.displayName,
                                    groupData.groupToken.groupId,
                                    accountInfo.getEmail()));
        }
    }

    private void openGroup(SavedTabGroup savedTabGroup) {
        @TabGroupState int state = getState(savedTabGroup);
        if (state == TabGroupState.IN_ANOTHER) {
            return;
        }

        if (state == TabGroupState.HIDDEN) {
            RecordUserAction.record("SyncedTabGroup.OpenNewLocal");
        } else {
            RecordUserAction.record("SyncedTabGroup.OpenExistingLocal");
        }

        if (state == TabGroupState.IN_CURRENT_CLOSING) {
            for (SavedTabGroupTab savedTab : savedTabGroup.savedTabs) {
                if (savedTab.localId != null) {
                    mFilter.getTabModel().cancelTabClosure(savedTab.localId);
                }
            }
        } else if (state == TabGroupState.HIDDEN) {
            String syncId = savedTabGroup.syncId;
            mTabGroupUiActionHandler.openTabGroup(syncId);
            savedTabGroup = mTabGroupSyncService.getGroup(syncId);
            assert savedTabGroup.localId != null;
        }

        int rootId = mFilter.getRootIdFromStableId(savedTabGroup.localId.tabGroupId);
        assert rootId != Tab.INVALID_TAB_ID;
        mPaneManager.focusPane(PaneId.TAB_SWITCHER);
        TabSwitcherPaneBase tabSwitcherPaneBase =
                (TabSwitcherPaneBase) mPaneManager.getPaneForId(PaneId.TAB_SWITCHER);
        boolean success = tabSwitcherPaneBase.requestOpenTabGroupDialog(rootId);
        assert success;
    }

    private void processDeleteGroup(SavedTabGroup savedTabGroup) {
        mActionConfirmationManager.processDeleteGroupAttempt(
                (@ConfirmationResult Integer result) -> {
                    if (result != ConfirmationResult.CONFIRMATION_NEGATIVE) {
                        deleteGroup(savedTabGroup);
                    }
                });
    }

    private void processDeleteSharedGroup(String groupTitle, String groupId) {
        mActionConfirmationManager.processDeleteSharedGroupAttempt(
                groupTitle,
                (@ConfirmationResult Integer result) -> {
                    if (result != ConfirmationResult.CONFIRMATION_NEGATIVE) {
                        // TODO(crbug.com/363040815): Implement callback handling.
                        mDataSharingService.deleteGroup(groupId, (ignored) -> {});
                    }
                });
    }

    private void processLeaveGroup(String groupTitle, String groupId, String memberEmail) {
        mActionConfirmationManager.processLeaveGroupAttempt(
                groupTitle,
                (@ConfirmationResult Integer result) -> {
                    if (result != ConfirmationResult.CONFIRMATION_NEGATIVE) {
                        // TODO(crbug.com/363040815): Implement callback handling.
                        mDataSharingService.removeMember(groupId, memberEmail, (ignored) -> {});
                    }
                });
    }

    private void deleteGroup(SavedTabGroup savedTabGroup) {
        @TabGroupState int state = getState(savedTabGroup);
        if (state == TabGroupState.IN_ANOTHER) {
            return;
        }

        if (state == TabGroupState.HIDDEN) {
            RecordUserAction.record("SyncedTabGroup.DeleteWithoutLocal");
        } else {
            RecordUserAction.record("SyncedTabGroup.DeleteWithLocal");
        }

        if (state == TabGroupState.IN_CURRENT_CLOSING) {
            for (SavedTabGroupTab savedTab : savedTabGroup.savedTabs) {
                if (savedTab.localId != null) {
                    mFilter.getTabModel().commitTabClosure(savedTab.localId);
                }
            }
            // Because the pending closure might have been hiding or part of a closure containing
            // more tabs we need to forcibly remove the group.
            mTabGroupSyncService.removeGroup(savedTabGroup.syncId);
        } else if (state == TabGroupState.IN_CURRENT) {
            int rootId = mFilter.getRootIdFromStableId(savedTabGroup.localId.tabGroupId);
            List<Tab> tabsToClose = mFilter.getRelatedTabListForRootId(rootId);
            mFilter.closeTabs(TabClosureParams.closeTabs(tabsToClose).allowUndo(false).build());
        } else {
            mTabGroupSyncService.removeGroup(savedTabGroup.syncId);
        }
    }
}