chromium/chrome/browser/tab_group/java/src/org/chromium/chrome/browser/tasks/tab_groups/TabGroupColorUtils.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_groups;

import android.content.Context;
import android.content.SharedPreferences;

import androidx.annotation.VisibleForTesting;
import androidx.collection.ArrayMap;

import org.chromium.base.ContextUtils;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.tab_groups.TabGroupColorId;

import java.util.Map;
import java.util.Set;

/** Helper class to handle tab group color related utilities. */
public class TabGroupColorUtils {
    public static final int INVALID_COLOR_ID = -1;
    private static final String TAB_GROUP_COLORS_FILE_NAME = "tab_group_colors";
    private static final String MIGRATION_CHECK = "migration_check";
    private static final int MIGRATION_NOT_DONE = 0;
    private static final int MIGRATION_DONE = 1;

    /**
     * This method stores tab group colors with reference to {@code tabRootId}. Package protected as
     * all access should route through the {@link TabGroupModelFilter}.
     *
     * @param tabRootId The tab root ID which is used as a reference to store group colors.
     * @param color The tab group color {@link TabGroupColorId} to store.
     */
    static void storeTabGroupColor(int tabRootId, int color) {
        assert tabRootId != Tab.INVALID_TAB_ID;
        getSharedPreferences().edit().putInt(String.valueOf(tabRootId), color).apply();
    }

    /**
     * This method deletes a specific stored tab group color with reference to {@code tabRootId}.
     * While currently public, the intent is to make this package protected and force all access to
     * go through the {@Link TabGroupModelFilter}.
     *
     * @param tabRootId The tab root ID whose related tab group color will be deleted.
     */
    static void deleteTabGroupColor(int tabRootId) {
        assert tabRootId != Tab.INVALID_TAB_ID;
        getSharedPreferences().edit().remove(String.valueOf(tabRootId)).apply();
    }

    /**
     * This method fetches tab group colors for the related tab group root ID. While currently
     * public, the intent is to make thisUndo package protected and force all access to go through
     * the {@Link TabGroupModelFilter}.
     *
     * @param tabRootId The tab root ID whose related tab group color will be fetched.
     * @return The stored color of the target tab group, default value is -1 (INVALID_COLOR_ID).
     */
    static int getTabGroupColor(int tabRootId) {
        assert tabRootId != Tab.INVALID_TAB_ID;
        return getSharedPreferences().getInt(String.valueOf(tabRootId), INVALID_COLOR_ID);
    }

    /**
     * This method assigns a color to all tab groups which do not have an assigned tab color at
     * startup. If a migration for all existing tabs has already been performed, skip this logic.
     *
     * @param tabGroupModelFilter The {@TabGroupModelFilter} that governs the current tab groups.
     */
    public static void assignTabGroupColorsIfApplicable(TabGroupModelFilter tabGroupModelFilter) {
        // TODO(b/41490324): Consider removing the migration logic when tab group colors are
        // launched. There may be an argument to keep this around in case the color info is somehow
        // lost between startups, in which case this will at least set some default colors up. In
        // theory, once the migrations have been applied to everyone there won't be a need for this.
        //
        // If the migration is already done, skip the below logic.
        if (getSharedPreferences().getInt(MIGRATION_CHECK, MIGRATION_NOT_DONE) == MIGRATION_DONE) {
            return;
        }

        Map<Integer, Integer> currentColorCountMap = getCurrentColorCountMap(tabGroupModelFilter);
        Set<Integer> rootIds = tabGroupModelFilter.getAllTabGroupRootIds();

        // Assign a color to all tab groups that don't have a color.
        for (Integer rootId : rootIds) {
            int colorId = getTabGroupColor(rootId);

            // Retrieve the next suggested colorId if the current tab group does not have a color.
            if (colorId == INVALID_COLOR_ID) {
                int suggestedColorId = getNextSuggestedColorId(currentColorCountMap);
                storeTabGroupColor(rootId, suggestedColorId);
                currentColorCountMap.put(
                        suggestedColorId, currentColorCountMap.get(suggestedColorId) + 1);
            }
        }

        // Mark that the initial migration of tab colors is complete.
        getSharedPreferences().edit().putInt(MIGRATION_CHECK, MIGRATION_DONE).apply();
    }

    /**
     * This method returns the next suggested colorId to be assigned to a tab group if that tab
     * group has no color assigned to it. This algorithm uses a key-value map to store all usage
     * counts of the current list of colors in other tab groups. It will select the least used color
     * that appears first in the color list. The suggested color value should be a color id of type
     * {@link TabGroupColorId}.
     *
     * @param tabGroupModelFilter The {@link TabGroupModelFilter} that governs all tab groups.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public static int getNextSuggestedColorId(TabGroupModelFilter tabGroupModelFilter) {
        // Generate the currentColorCountMap.
        Map<Integer, Integer> currentColorCountMap = getCurrentColorCountMap(tabGroupModelFilter);
        return getNextSuggestedColorId(currentColorCountMap);
    }

    /**
     * This method removes the shared preference file. TODO(b/41490324): Consider removing this when
     * the feature is launched.
     */
    public static void clearTabGroupColorInfo() {
        ContextUtils.getApplicationContext().deleteSharedPreferences(TAB_GROUP_COLORS_FILE_NAME);
    }

    private static SharedPreferences getSharedPreferences() {
        return ContextUtils.getApplicationContext()
                .getSharedPreferences(TAB_GROUP_COLORS_FILE_NAME, Context.MODE_PRIVATE);
    }

    /** Get a map that indicates the current usage count of each tab group color. */
    private static Map<Integer, Integer> getCurrentColorCountMap(
            TabGroupModelFilter tabGroupModelFilter) {
        int colorListSize = TabGroupColorId.NUM_ENTRIES;
        Map<Integer, Integer> colorCountMap = new ArrayMap<>(colorListSize);
        for (int i = 0; i < colorListSize; i++) {
            colorCountMap.put(i, 0);
        }

        Set<Integer> rootIds = tabGroupModelFilter.getAllTabGroupRootIds();

        // Filter all tab groups for ones that already have a color assigned.
        for (Integer rootId : rootIds) {
            int colorId = getTabGroupColor(rootId);

            // If the tab group has a color stored on shared prefs, increment the colorId map count.
            if (colorId != INVALID_COLOR_ID) {
                colorCountMap.put(colorId, colorCountMap.get(colorId) + 1);
            }
        }

        return colorCountMap;
    }

    /** Impl of getNextSuggestedColorId which assumes a currentColorCountMap has been created. */
    private static int getNextSuggestedColorId(Map<Integer, Integer> currentColorCountMap) {
        int colorId = Integer.MAX_VALUE;
        int colorCount = Integer.MAX_VALUE;

        for (Map.Entry<Integer, Integer> entry : currentColorCountMap.entrySet()) {
            if (entry.getValue() < colorCount) {
                colorCount = entry.getValue();
                colorId = entry.getKey();
            } else if (entry.getValue() == colorCount) {
                if (entry.getKey() < colorId) {
                    colorId = entry.getKey();
                }
            }
        }

        // Assert that the current color count map exists and sets a valid colorId on loop
        // iteration, otherwise default to an invalid colorId.
        assert colorId != Integer.MAX_VALUE;
        return colorId != Integer.MAX_VALUE ? colorId : INVALID_COLOR_ID;
    }
}