[Data Sharing] Add utils to find collaborations destroyed by tab removal
Add utility methods that check a list of tabs that are closing or being
ungrouped to see if any collaborations would be destroyed by the
operation. If so return the list of LocalTabGroupIds to which the
destruction will happen.
The output of these utilities will allow us to detect operations which
need interception and show a warning dialog and/or apply special
handling as specified by UX.
Bug: 345854441
Change-Id: I2468a6df2a48d5943f3176e1f9f3746001b25b13
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5844571
Commit-Queue: Calder Kitagawa <[email protected]>
Code-Coverage: [email protected] <[email protected]>
Reviewed-by: Sky Malice <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1352917}
diff --git a/chrome/browser/data_sharing/BUILD.gn b/chrome/browser/data_sharing/BUILD.gn
index 7b6ed7f..e31b55b 100644
--- a/chrome/browser/data_sharing/BUILD.gn
+++ b/chrome/browser/data_sharing/BUILD.gn
@@ -44,6 +44,7 @@
android_library("tab_group_ui_java") {
resources_package = "org.chromium.chrome.browser.data_sharing"
sources = [
+ "android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtils.java",
"android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabManager.java",
"android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabSwitcherDelegate.java",
"android/java/src/org/chromium/chrome/browser/data_sharing/TabGridDialogShareBottomSheetContent.java",
@@ -61,6 +62,7 @@
"//chrome/browser/tab_group:java",
"//chrome/browser/tab_group_sync:factory_java",
"//chrome/browser/tab_group_sync:java",
+ "//chrome/browser/tabmodel:java",
"//chrome/browser/ui/android/strings:ui_strings_grd",
"//components/browser_ui/bottomsheet/android:java",
"//components/browser_ui/notifications/android:java",
@@ -184,6 +186,7 @@
robolectric_library("junit") {
sources = [
"android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingNotificationManagerUnitTest.java",
+ "android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtilsUnitTest.java",
"android/java/src/org/chromium/chrome/browser/data_sharing/ui/shared_image_tiles/SharedImageTilesCoordinatorUnitTest.java",
]
deps = [
@@ -192,9 +195,16 @@
"//base:base_java",
"//base:base_junit_test_support",
"//chrome/browser/notifications:java",
+ "//chrome/browser/profiles/android:java",
+ "//chrome/browser/tab:java",
+ "//chrome/browser/tab_group_sync:factory_java",
+ "//chrome/browser/tabmodel:java",
+ "//chrome/test/android:chrome_java_unit_test_support",
"//components/browser_ui/notifications/android:java",
"//components/data_sharing/public:public_java",
+ "//components/saved_tab_groups:java",
"//third_party/android_deps:robolectric_all_java",
+ "//third_party/androidx:androidx_annotation_annotation_java",
"//third_party/androidx:androidx_test_core_java",
"//third_party/junit:junit",
"//third_party/mockito:mockito_java",
diff --git a/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtils.java b/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtils.java
new file mode 100644
index 0000000..4c298d0
--- /dev/null
+++ b/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtils.java
@@ -0,0 +1,116 @@
+// 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.data_sharing;
+
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.chromium.chrome.browser.tab.Tab;
+import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncServiceFactory;
+import org.chromium.chrome.browser.tabmodel.TabClosureParams;
+import org.chromium.chrome.browser.tabmodel.TabModel;
+import org.chromium.chrome.browser.tabmodel.TabModelUtils;
+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.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Utilities related to tab groups in data sharing. */
+public class DataSharingTabGroupUtils {
+ /**
+ * Returns the list of local tab group IDs with collaborations that closing or ungrouping the
+ * list of tabs would destroy.
+ *
+ * @param tabModel The tab model to close or ungroup tabs in.
+ * @param tabsToRemove The list of tabs to remove.
+ * @return A list of the local tab groups IDs that would have collaborations destroyed, or an
+ * empty list if none.
+ */
+ @NonNull
+ public static List<LocalTabGroupId> getCollaborationsDestroyedByTabRemoval(
+ @NonNull TabModel tabModel, @Nullable List<Tab> tabsToRemove) {
+ // TODO(crbug.com/345854441): Add feature flag checks.
+
+ // Collaborations are not possible in incognito branded mode.
+ if (tabsToRemove == null || tabsToRemove.isEmpty() || tabModel.isIncognitoBranded()) {
+ return Collections.emptyList();
+ }
+
+ List<LocalTabGroupId> groupIds = new ArrayList<>();
+ @Nullable
+ TabGroupSyncService tabGroupSyncService =
+ TabGroupSyncServiceFactory.getForProfile(tabModel.getProfile());
+ if (tabGroupSyncService == null) {
+ return Collections.emptyList();
+ }
+
+ for (String syncId : tabGroupSyncService.getAllGroupIds()) {
+ SavedTabGroup group = tabGroupSyncService.getGroup(syncId);
+
+ // Tab groups without collaborations are not of interest since there is no risk if they
+ // are destroyed. Tab groups without a local representation won't have local tabs that
+ // are being removed and can also be skipped.
+ if (group.localId == null || TextUtils.isEmpty(group.collaborationId)) continue;
+
+ if (willRemoveAllTabsInGroup(group.savedTabs, tabsToRemove)) {
+ groupIds.add(group.localId);
+ }
+ }
+ return groupIds;
+ }
+
+ /**
+ * Returns the list of local tab group IDs with collaborations that closing the tabs described
+ * by the closure params would destroy.
+ *
+ * @param tabModel The tab model to close tabs in.
+ * @param closureParams The params that would be used to close tabs.
+ * @return A list of the local tab group IDs that would have collaborations destroyed, or an
+ * empty list if none.
+ */
+ public static @NonNull List<LocalTabGroupId> getCollaborationsDestroyedByTabClosure(
+ @NonNull TabModel tabModel, @NonNull TabClosureParams closureParams) {
+ // If tab groups are being hidden then they cannot be destroyed.
+ if (closureParams.hideTabGroups) return Collections.emptyList();
+
+ @Nullable
+ List<Tab> tabsToClose =
+ closureParams.isAllTabs
+ ? TabModelUtils.convertTabListToListOfTabs(tabModel)
+ : closureParams.tabs;
+ return getCollaborationsDestroyedByTabRemoval(tabModel, tabsToClose);
+ }
+
+ private static boolean willRemoveAllTabsInGroup(
+ List<SavedTabGroupTab> savedTabs, List<Tab> tabsToRemove) {
+ for (SavedTabGroupTab savedTab : savedTabs) {
+ // First check that we have local IDs for the tab. It is possible that we don't if the
+ // tab group is open in another window that hasn't been foregrounded yet as the tabs are
+ // loaded lazily and so won't be tracked yet. If this happens we won't destroy the
+ // collaboration as the tabs cannot be removed.
+ //
+ // If any of the tabs in the saved group are missing from the list of tabsToRemove we
+ // can assume the collaboration will not be destroyed and early out. This check is
+ // technically O(n^2) if every group is a collaboration and all tabs are closing. We
+ // could optimize this with sets, but then the average case performance is likely to
+ // be worse as realistically very few entries will be shared. We can revisit this if we
+ // start seeing ANRs or other issues.
+ if (savedTab.localId == null
+ || !tabsToRemove.stream()
+ .filter(tab -> tab.getId() == savedTab.localId)
+ .findFirst()
+ .isPresent()) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtilsUnitTest.java b/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtilsUnitTest.java
new file mode 100644
index 0000000..9f6296c
--- /dev/null
+++ b/chrome/browser/data_sharing/android/java/src/org/chromium/chrome/browser/data_sharing/DataSharingTabGroupUtilsUnitTest.java
@@ -0,0 +1,320 @@
+// 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.data_sharing;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+import androidx.annotation.Nullable;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import org.chromium.base.Token;
+import org.chromium.base.test.BaseRobolectricTestRunner;
+import org.chromium.chrome.browser.profiles.Profile;
+import org.chromium.chrome.browser.tab_group_sync.TabGroupSyncServiceFactory;
+import org.chromium.chrome.browser.tabmodel.TabClosureParams;
+import org.chromium.chrome.browser.tabmodel.TabModel;
+import org.chromium.chrome.test.util.browser.tabmodel.MockTabModel;
+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.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Unit tests for {@link DataSharingTabGroupUtils}. */
+@RunWith(BaseRobolectricTestRunner.class)
+public class DataSharingTabGroupUtilsUnitTest {
+ @Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ private static final String SYNC_SUFFIX = "_sync";
+ private static final String COLLABORATION_SUFFIX = "_collaboration";
+
+ private static final int TAB_ID_1 = 123;
+ private static final int TAB_ID_2 = 456;
+ private static final int TAB_ID_3 = 789;
+ private static final int TAB_ID_4 = 433289;
+ private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_1 =
+ new LocalTabGroupId(new Token(1L, 1L));
+ private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_2 =
+ new LocalTabGroupId(new Token(2L, 2L));
+ private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_3 =
+ new LocalTabGroupId(new Token(3L, 3L));
+
+ @Mock private Profile mRegularProfile;
+ @Mock private Profile mOtrProfile;
+ @Mock private TabGroupSyncService mTabGroupSyncService;
+
+ @Before
+ public void setUp() {
+ when(mRegularProfile.isOffTheRecord()).thenReturn(false);
+ when(mOtrProfile.isOffTheRecord()).thenReturn(true);
+
+ TabGroupSyncServiceFactory.setForTesting(mTabGroupSyncService);
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_NullList() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, /* tabsToRemove= */ null);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_EmptyList() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, /* tabsToRemove= */ Collections.emptyList());
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_IncognitoTabModel() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ true);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, List.of(tabModel.getTabById(TAB_ID_1)));
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_NoLocalGroup() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ /* localTabGroupId= */ null,
+ List.of(TAB_ID_1),
+ /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, List.of(tabModel.getTabById(TAB_ID_1)));
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_NoCollaboration() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ false));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, List.of(tabModel.getTabById(TAB_ID_1)));
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_NotAllClosing() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1,
+ List.of(TAB_ID_1, TAB_ID_2),
+ /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, List.of(tabModel.getTabById(TAB_ID_1)));
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_AllClosing_1Tab() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel, List.of(tabModel.getTabById(TAB_ID_1)));
+ assertEquals(LOCAL_TAB_GROUP_ID_1, result.get(0));
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabRemoval_AllClosing_2Tab() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1,
+ List.of(TAB_ID_1, TAB_ID_2),
+ /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabRemoval(
+ tabModel,
+ List.of(tabModel.getTabById(TAB_ID_1), tabModel.getTabById(TAB_ID_2)));
+ assertEquals(LOCAL_TAB_GROUP_ID_1, result.get(0));
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabClosure_NoTabs() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+ var params = TabClosureParams.closeTabs(Collections.emptyList()).build();
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabClosure(tabModel, params);
+
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabClosure_SomeTabs_NotHiding() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+ var params = TabClosureParams.closeTabs(List.of(tabModel.getTabById(TAB_ID_1))).build();
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabClosure(tabModel, params);
+
+ assertEquals(LOCAL_TAB_GROUP_ID_1, result.get(0));
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabClosure_SomeTabs_Hiding() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1, List.of(TAB_ID_1), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+ var params =
+ TabClosureParams.closeTabs(List.of(tabModel.getTabById(TAB_ID_1)))
+ .hideTabGroups(true)
+ .build();
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabClosure(tabModel, params);
+
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testGetCollaborationsDestroyedByTabClosure_AllTabs() {
+ List<TabGroupData> tabGroups = new ArrayList<>();
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_1,
+ List.of(TAB_ID_1, TAB_ID_2),
+ /* isCollaboration= */ true));
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_2, List.of(TAB_ID_3), /* isCollaboration= */ false));
+ tabGroups.add(
+ new TabGroupData(
+ LOCAL_TAB_GROUP_ID_3, List.of(TAB_ID_4), /* isCollaboration= */ true));
+ var tabModel = createTabGroups(tabGroups, /* isIncognito= */ false);
+ var params = TabClosureParams.closeAllTabs().build();
+
+ List<LocalTabGroupId> result =
+ DataSharingTabGroupUtils.getCollaborationsDestroyedByTabClosure(tabModel, params);
+
+ assertEquals(LOCAL_TAB_GROUP_ID_1, result.get(0));
+ assertEquals(LOCAL_TAB_GROUP_ID_3, result.get(1));
+ }
+
+ private static class TabGroupData {
+ public final @Nullable LocalTabGroupId localTabGroupId;
+ public final List<Integer> tabIds;
+ public final boolean isCollaboration;
+
+ TabGroupData(
+ @Nullable LocalTabGroupId localTabGroupId,
+ List<Integer> tabIds,
+ boolean isCollaboration) {
+ this.localTabGroupId = localTabGroupId;
+ this.tabIds = tabIds;
+ this.isCollaboration = isCollaboration;
+ }
+ }
+
+ private TabModel createTabGroups(List<TabGroupData> groups, boolean isIncognito) {
+ MockTabModel mockTabModel =
+ new MockTabModel(isIncognito ? mOtrProfile : mRegularProfile, /* delegate= */ null);
+
+ List<SavedTabGroup> savedGroups = new ArrayList<>();
+ List<String> savedGroupSyncIds = new ArrayList<>();
+ for (TabGroupData group : groups) {
+ List<SavedTabGroupTab> savedTabs = new ArrayList<>();
+ for (int tabId : group.tabIds) {
+ mockTabModel.addTab(tabId);
+
+ SavedTabGroupTab savedTab = new SavedTabGroupTab();
+ savedTab.localId = tabId;
+ savedTabs.add(savedTab);
+ }
+
+ SavedTabGroup savedGroup = new SavedTabGroup();
+ // Use hashcode as unique placeholder if non-local group.
+ String groupIdString =
+ group.localTabGroupId != null
+ ? group.localTabGroupId.tabGroupId.toString()
+ : String.valueOf(group.hashCode());
+ savedGroup.syncId = groupIdString + SYNC_SUFFIX;
+ savedGroup.localId = group.localTabGroupId;
+ savedGroup.savedTabs = savedTabs;
+ if (group.isCollaboration) {
+ savedGroup.collaborationId = groupIdString + COLLABORATION_SUFFIX;
+ }
+
+ savedGroupSyncIds.add(savedGroup.syncId);
+ savedGroups.add(savedGroup);
+ }
+
+ if (!isIncognito) {
+ when(mTabGroupSyncService.getAllGroupIds())
+ .thenReturn(savedGroupSyncIds.toArray(new String[0]));
+ for (int i = 0; i < savedGroupSyncIds.size(); i++) {
+ when(mTabGroupSyncService.getGroup(savedGroupSyncIds.get(i)))
+ .thenReturn(savedGroups.get(i));
+ }
+ } else {
+ when(mTabGroupSyncService.getAllGroupIds()).thenReturn(new String[] {});
+ }
+
+ return mockTabModel;
+ }
+}