// 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 android.content.Context;
import android.content.res.Resources;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.util.Function;
import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_management.ActionConfirmationDialog.ConfirmationDialogResult;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.prefs.PrefService;
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.user_prefs.UserPrefs;
import org.chromium.ui.modaldialog.ModalDialogManager;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* Many tab actions can now cause deletion of tab groups. This class helps orchestrates flows where
* we might want to warn the user that they're about to delete a tab group.
*/
public class ActionConfirmationManager {
private static final String TAB_GROUP_CONFIRMATION = "TabGroupConfirmation.";
private static final String DELETE_GROUP_USER_ACTION = TAB_GROUP_CONFIRMATION + "DeleteGroup.";
private static final String DELETE_SHARED_GROUP_USER_ACTION =
TAB_GROUP_CONFIRMATION + "DeleteSharedGroup.";
private static final String UNGROUP_USER_ACTION = TAB_GROUP_CONFIRMATION + "Ungroup.";
private static final String REMOVE_TAB_USER_ACTION = TAB_GROUP_CONFIRMATION + "RemoveTab.";
private static final String REMOVE_TAB_FULL_GROUP_USER_ACTION =
TAB_GROUP_CONFIRMATION + "RemoveTabFullGroup.";
private static final String CLOSE_TAB_USER_ACTION = TAB_GROUP_CONFIRMATION + "CloseTab.";
private static final String CLOSE_TAB_FULL_GROUP_USER_ACTION =
TAB_GROUP_CONFIRMATION + "CloseTabFullGroup.";
private static final String LEAVE_GROUP_USER_ACTION = TAB_GROUP_CONFIRMATION + "LeaveGroup.";
// The result of processing an action.
@IntDef({
ConfirmationResult.IMMEDIATE_CONTINUE,
ConfirmationResult.CONFIRMATION_POSITIVE,
ConfirmationResult.CONFIRMATION_NEGATIVE
})
@Retention(RetentionPolicy.SOURCE)
public @interface ConfirmationResult {
// Did not show any confirmation, the action should immediately continue. Resulting action
// should likely be undoable.
int IMMEDIATE_CONTINUE = 0;
// Confirmation was received from the user to continue the action. Do not make resulting
// action undoable.
int CONFIRMATION_POSITIVE = 1;
// The user wants to cancel the action.
int CONFIRMATION_NEGATIVE = 2;
}
private final Profile mProfile;
private final Context mContext;
private final TabGroupModelFilter mTabGroupModelFilter;
private final ModalDialogManager mModalDialogManager;
/**
* @param profile The profile to access shared services with.
* @param context Used to load android resources.
* @param tabGroupModelFilter Used to read tab data.
* @param modalDialogManager Used to show dialogs.
*/
public ActionConfirmationManager(
Profile profile,
Context context,
TabGroupModelFilter tabGroupModelFilter,
@NonNull ModalDialogManager modalDialogManager) {
assert modalDialogManager != null;
mProfile = profile;
mContext = context;
mTabGroupModelFilter = tabGroupModelFilter;
mModalDialogManager = modalDialogManager;
}
/**
* A close group is an operation on tab group(s), and while it may contain non-grouped tabs, it
* is not an action on individual tabs within a group.
*/
public void processDeleteGroupAttempt(Callback<Integer> onResult) {
processMaybeSyncAndPrefAction(
DELETE_GROUP_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_CLOSE,
R.string.delete_tab_group_dialog_title,
R.string.delete_tab_group_description,
R.string.delete_tab_group_no_sync_description,
R.string.delete_tab_group_action,
onResult);
}
/** Processes deleting a shared group, the user should be the owner. */
public void processDeleteSharedGroupAttempt(String groupTitle, Callback<Integer> onResult) {
processGroupNameAction(
DELETE_SHARED_GROUP_USER_ACTION,
R.string.delete_tab_group_dialog_title,
R.string.delete_shared_tab_group_description,
groupTitle,
R.string.delete_tab_group_menu_item,
onResult);
}
/** Ungroup is an action taken on tab groups that ungroups every tab within them. */
public void processUngroupAttempt(Callback<Integer> onResult) {
processMaybeSyncAndPrefAction(
UNGROUP_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_UNGROUP,
R.string.ungroup_tab_group_dialog_title,
R.string.ungroup_tab_group_description,
R.string.ungroup_tab_group_no_sync_description,
R.string.ungroup_tab_group_action,
onResult);
}
/**
* Removing tabs either moving to no group or to a different group. The caller needs to ensure
* this action will delete the group.
*/
public void processRemoveTabAttempt(Callback<Integer> onResult) {
processMaybeSyncAndPrefAction(
REMOVE_TAB_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_REMOVE,
R.string.remove_from_group_dialog_message,
R.string.remove_from_group_description,
R.string.delete_tab_group_no_sync_description,
R.string.delete_tab_group_action,
onResult);
}
/**
* Removing tabs is ungrouping through the dialog bottom bar, selecting tabs and ungrouping, or
* by dragging out of the strip. The list of tabs should all be in the same group.
*/
public void processRemoveTabAttempt(List<Integer> tabIdList, Callback<Integer> onResult) {
if (isFullGroup(tabIdList)) {
processMaybeSyncAndPrefAction(
REMOVE_TAB_FULL_GROUP_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_REMOVE,
R.string.remove_from_group_dialog_message,
R.string.remove_from_group_description,
R.string.delete_tab_group_no_sync_description,
R.string.delete_tab_group_action,
onResult);
} else {
onResult.onResult(ConfirmationResult.IMMEDIATE_CONTINUE);
}
}
/**
* This processes closing tabs within groups. The caller needs to ensure this action will delete
* the group.
*/
public void processCloseTabAttempt(Callback<Integer> onResult) {
processMaybeSyncAndPrefAction(
CLOSE_TAB_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_CLOSE,
R.string.close_from_group_dialog_title,
R.string.close_from_group_description,
R.string.delete_tab_group_no_sync_description,
R.string.delete_tab_group_action,
onResult);
}
/**
* This processes closing tabs within groups. Warns when the last tab(s) are being closed. The
* list of tabs should all be in the same group.
*/
public void processCloseTabAttempt(List<Integer> tabIdList, Callback<Integer> onResult) {
if (isFullGroup(tabIdList)) {
processMaybeSyncAndPrefAction(
CLOSE_TAB_FULL_GROUP_USER_ACTION,
Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_CLOSE,
R.string.close_from_group_dialog_title,
R.string.close_from_group_description,
R.string.delete_tab_group_no_sync_description,
R.string.delete_tab_group_action,
onResult);
} else {
onResult.onResult(ConfirmationResult.IMMEDIATE_CONTINUE);
}
}
/** Processing leaving a shared group. */
public void processLeaveGroupAttempt(String groupTitle, Callback<Integer> onResult) {
processGroupNameAction(
LEAVE_GROUP_USER_ACTION,
R.string.leave_tab_group_dialog_title,
R.string.leave_tab_group_description,
groupTitle,
R.string.leave_tab_group_menu_item,
onResult);
}
private boolean isFullGroup(List<Integer> tabIdList) {
assert mTabGroupModelFilter != null : "TabGroupModelFilter has not been set";
return tabIdList.size() >= mTabGroupModelFilter.getRelatedTabList(tabIdList.get(0)).size();
}
private void processMaybeSyncAndPrefAction(
String userActionBaseString,
@Nullable String stopShowingPref,
@StringRes int titleRes,
@StringRes int withSyncDescriptionRes,
@StringRes int noSyncDescriptionRes,
@StringRes int actionRes,
Callback<Integer> onResult) {
boolean syncingTabGroups = false;
@Nullable SyncService syncService = SyncServiceFactory.getForProfile(mProfile);
if (syncService != null) {
syncingTabGroups = syncService.getActiveDataTypes().contains(DataType.SAVED_TAB_GROUP);
}
@Nullable CoreAccountInfo coreAccountInfo = getCoreAccountInfo();
final Function<Resources, String> titleResolver = (res) -> res.getString(titleRes);
final Function<Resources, String> descriptionResolver;
if (syncingTabGroups && coreAccountInfo != null) {
descriptionResolver =
(resources ->
resources.getString(
withSyncDescriptionRes, coreAccountInfo.getEmail()));
} else {
descriptionResolver = (resources -> resources.getString(noSyncDescriptionRes));
}
PrefService prefService = UserPrefs.get(mProfile);
if (prefService.getBoolean(stopShowingPref)) {
onResult.onResult(ConfirmationResult.IMMEDIATE_CONTINUE);
return;
}
ConfirmationDialogResult onDialogResult =
(takePositiveAction, resultStopShowing) -> {
if (resultStopShowing) {
RecordUserAction.record(userActionBaseString + "StopShowing");
prefService.setBoolean(stopShowingPref, true);
}
handleDialogResult(takePositiveAction, userActionBaseString, onResult);
};
ActionConfirmationDialog dialog =
new ActionConfirmationDialog(mContext, mModalDialogManager);
dialog.show(
titleResolver,
descriptionResolver,
actionRes,
/* supportStopShowing= */ true,
onDialogResult);
}
private @Nullable CoreAccountInfo getCoreAccountInfo() {
IdentityServicesProvider identityServicesProvider = IdentityServicesProvider.get();
@Nullable
IdentityManager identityManager = identityServicesProvider.getIdentityManager(mProfile);
if (identityManager != null) {
return identityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
} else {
return null;
}
}
private void processGroupNameAction(
String userActionBaseString,
@StringRes int titleRes,
@StringRes int descriptionRes,
String formatArg,
@StringRes int actionRes,
Callback<Integer> onResult) {
final Function<Resources, String> titleResolver = (res) -> res.getString(titleRes);
final Function<Resources, String> descriptionResolver =
(resources -> resources.getString(descriptionRes, formatArg));
ConfirmationDialogResult onDialogResult =
(takePositiveAction, resultStopShowing) ->
handleDialogResult(takePositiveAction, userActionBaseString, onResult);
ActionConfirmationDialog dialog =
new ActionConfirmationDialog(mContext, mModalDialogManager);
dialog.show(
titleResolver,
descriptionResolver,
actionRes,
/* supportStopShowing= */ false,
onDialogResult);
}
private void handleDialogResult(
boolean takePositiveAction, String userActionBaseString, Callback<Integer> onResult) {
RecordUserAction.record(userActionBaseString + (takePositiveAction ? "Proceed" : "Abort"));
onResult.onResult(
takePositiveAction
? ConfirmationResult.CONFIRMATION_POSITIVE
: ConfirmationResult.CONFIRMATION_NEGATIVE);
}
public static void clearStopShowingPrefsForTesting(PrefService prefService) {
prefService.clearPref(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_CLOSE);
prefService.clearPref(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_UNGROUP);
prefService.clearPref(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_REMOVE);
prefService.clearPref(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_CLOSE);
}
public static void setAllStopShowingPrefsForTesting(PrefService prefService) {
prefService.setBoolean(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_CLOSE, true);
prefService.setBoolean(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_UNGROUP, true);
prefService.setBoolean(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_REMOVE, true);
prefService.setBoolean(Pref.STOP_SHOWING_TAB_GROUP_CONFIRMATION_ON_TAB_CLOSE, true);
}
}