chromium/chrome/android/java/src/org/chromium/chrome/browser/omaha/UpdateMenuItemHelper.java

// Copyright 2015 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.omaha;

import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.res.Resources;
import android.text.TextUtils;
import android.view.Choreographer;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.chromium.base.BuildInfo;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.omaha.UpdateStatusProvider.UpdateState;
import org.chromium.chrome.browser.omaha.UpdateStatusProvider.UpdateStatus;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileKeyedMap;
import org.chromium.chrome.browser.toolbar.menu_button.MenuButtonState;
import org.chromium.chrome.browser.toolbar.menu_button.MenuItemState;
import org.chromium.chrome.browser.toolbar.menu_button.MenuUiState;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.user_prefs.UserPrefs;

/**
 * Contains logic related to displaying app menu badge and a special menu item for information
 * related to updates.
 *
 * It supports displaying a badge and item for whether an update is available, and a different
 * badge and menu item if the Android OS version Chrome is currently running on is unsupported.
 *
 * It also has logic for logging usage of the update menu item to UMA.
 *
 * For manually testing this functionality, see {@link UpdateConfigs}.
 */
public class UpdateMenuItemHelper {
    private static final String TAG = "UpdateMenuItemHelper";

    private static UpdateMenuItemHelper sInstanceForTesting;
    private static ProfileKeyedMap<UpdateMenuItemHelper> sProfileMap;

    private static Object sGetInstanceLock = new Object();

    private final Profile mProfile;
    private final ObserverList<Runnable> mObservers = new ObserverList<>();

    private final Callback<UpdateStatusProvider.UpdateStatus> mUpdateCallback =
            status -> {
                mStatus = status;
                handleStateChanged();
                pingObservers();
            };

    /**
     * The current state of updates for Chrome. This can change during runtime and may be {@code
     * null} if the status hasn't been determined yet.
     *
     * <p>TODO(crbug.com/40610457): Handle state bug where the state here and the visible state of
     * the UI can be out of sync.
     */
    private @Nullable UpdateStatus mStatus;

    private @NonNull MenuUiState mMenuUiState = new MenuUiState();

    /**
     * Whether the runnable posted when the app menu is dismissed has been executed. Tracked for
     * testing.
     */
    private boolean mMenuDismissedRunnableExecuted;

    /** Return the {@link UpdateMenuItemHelper} for the given {@link Profile}. */
    public static UpdateMenuItemHelper getInstance(Profile profile) {
        synchronized (UpdateMenuItemHelper.sGetInstanceLock) {
            if (sInstanceForTesting != null) return sInstanceForTesting;
            if (sProfileMap == null) {
                sProfileMap = new ProfileKeyedMap<>(ProfileKeyedMap.NO_REQUIRED_CLEANUP_ACTION);
            }
            return sProfileMap.getForProfile(profile, UpdateMenuItemHelper::new);
        }
    }

    public static void setInstanceForTesting(UpdateMenuItemHelper testingInstance) {
        sInstanceForTesting = testingInstance;
        ResettersForTesting.register(() -> sInstanceForTesting = null);
    }

    private UpdateMenuItemHelper(Profile profile) {
        mProfile = profile;
    }

    /**
     * Registers {@code observer} to be triggered whenever the menu state changes.  This will always
     * be triggered at least once after registration.
     */
    public void registerObserver(Runnable observer) {
        if (!mObservers.addObserver(observer)) return;

        if (mStatus != null) {
            PostTask.postTask(
                    TaskTraits.UI_DEFAULT,
                    () -> {
                        if (mObservers.hasObserver(observer)) observer.run();
                    });
            return;
        }

        UpdateStatusProvider.getInstance().addObserver(mUpdateCallback);
    }

    /** Unregisters {@code observer} from menu state changes. */
    public void unregisterObserver(Runnable observer) {
        mObservers.removeObserver(observer);
    }

    /** @return {@link MenuUiState} representing the current update state for the menu. */
    public @NonNull MenuUiState getUiState() {
        return mMenuUiState;
    }

    /**
     * Handles a click on the update menu item.
     * @param activity The current {@code Activity}.
     */
    public void onMenuItemClicked(Activity activity) {
        if (mStatus == null) return;

        switch (mStatus.updateState) {
            case UpdateState.UPDATE_AVAILABLE:
                if (TextUtils.isEmpty(mStatus.updateUrl)) return;

                try {
                    UpdateStatusProvider.getInstance()
                            .startIntentUpdate(activity, /* newTask= */ false);
                } catch (ActivityNotFoundException e) {
                    Log.e(TAG, "Failed to launch Activity for: %s", mStatus.updateUrl);
                }
                break;
            case UpdateState.UNSUPPORTED_OS_VERSION:
                // Intentional fall through.
            default:
                return;
        }

        // If the update menu item is showing because it was forced on through about://flags
        // then mLatestVersion may be null.
        if (mStatus.latestVersion != null) {
            getPrefService()
                    .setString(
                            Pref.LATEST_VERSION_WHEN_CLICKED_UPDATE_MENU_ITEM,
                            mStatus.latestVersion);
        }

        handleStateChanged();
    }

    /** Called when the menu containing the update menu item is dismissed. */
    public void onMenuDismissed() {
        mMenuDismissedRunnableExecuted = false;
        // Post a task to record the item clicked histogram. Post task is used so that the runnable
        // executes after #onMenuItemClicked is called (if it's going to be called).
        Choreographer.getInstance()
                .postFrameCallback(
                        (long frameTimeNanos) -> {
                            mMenuDismissedRunnableExecuted = true;
                        });
    }

    /**
     * Called when the user clicks the app menu button while the unsupported OS badge is showing.
     */
    public void onMenuButtonClicked() {
        if (mStatus == null) return;
        if (mStatus.updateState != UpdateState.UNSUPPORTED_OS_VERSION) return;

        UpdateStatusProvider.getInstance().updateLatestUnsupportedVersion();
    }

    private void handleStateChanged() {
        assert mStatus != null;

        boolean showBadge = UpdateConfigs.getAlwaysShowMenuBadge();

        // Note that is not safe for theming, but for string access it should be ok.
        Resources resources = ContextUtils.getApplicationContext().getResources();

        mMenuUiState = new MenuUiState();
        switch (mStatus.updateState) {
            case UpdateState.UPDATE_AVAILABLE:
                // The badge is hidden if the update menu item has been clicked until there is an
                // even newer version of Chrome available.
                showBadge |=
                        !TextUtils.equals(
                                getPrefService()
                                        .getString(
                                                Pref.LATEST_VERSION_WHEN_CLICKED_UPDATE_MENU_ITEM),
                                mStatus.latestUnsupportedVersion);

                if (showBadge) {
                    mMenuUiState.buttonState = new MenuButtonState();
                    mMenuUiState.buttonState.menuContentDescription =
                            R.string.accessibility_toolbar_btn_menu_update;
                    mMenuUiState.buttonState.darkBadgeIcon = R.drawable.badge_update_dark;
                    mMenuUiState.buttonState.lightBadgeIcon = R.drawable.badge_update_light;
                    mMenuUiState.buttonState.adaptiveBadgeIcon = R.drawable.badge_update;
                }

                mMenuUiState.itemState = new MenuItemState();
                mMenuUiState.itemState.title = R.string.menu_update;
                mMenuUiState.itemState.titleColorId = R.color.default_text_color_error;
                mMenuUiState.itemState.icon = R.drawable.badge_update;
                mMenuUiState.itemState.enabled = true;
                mMenuUiState.itemState.summary = UpdateConfigs.getCustomSummary();
                if (TextUtils.isEmpty(mMenuUiState.itemState.summary)) {
                    mMenuUiState.itemState.summary =
                            resources.getString(R.string.menu_update_summary_default);
                }
                break;
            case UpdateState.UNSUPPORTED_OS_VERSION:
                // We should show the badge if the user has not opened the menu.
                showBadge |= mStatus.latestUnsupportedVersion == null;

                // In case the user has been upgraded since last time they tapped the toolbar badge
                // we should show the badge again.
                showBadge |=
                        !TextUtils.equals(
                                BuildInfo.getInstance().versionName,
                                mStatus.latestUnsupportedVersion);

                if (showBadge) {
                    mMenuUiState.buttonState = new MenuButtonState();
                    mMenuUiState.buttonState.menuContentDescription =
                            R.string.accessibility_toolbar_btn_menu_os_version_unsupported;
                    mMenuUiState.buttonState.darkBadgeIcon =
                            R.drawable.ic_error_grey800_24dp_filled;
                    mMenuUiState.buttonState.lightBadgeIcon = R.drawable.ic_error_white_24dp_filled;
                    mMenuUiState.buttonState.adaptiveBadgeIcon = R.drawable.ic_error_24dp_filled;
                }

                mMenuUiState.itemState = new MenuItemState();
                mMenuUiState.itemState.title = R.string.menu_update_unsupported;
                mMenuUiState.itemState.titleColorId = R.color.default_text_color_list;
                mMenuUiState.itemState.summary =
                        resources.getString(R.string.menu_update_unsupported_summary_default);
                mMenuUiState.itemState.icon = R.drawable.ic_error_24dp_filled;
                mMenuUiState.itemState.enabled = false;
                break;
            case UpdateState.NONE:
                // Intentional fall through.
            default:
                break;
        }
    }

    private void pingObservers() {
        for (Runnable observer : mObservers) observer.run();
    }

    private PrefService getPrefService() {
        return UserPrefs.get(mProfile);
    }

    boolean getMenuDismissedRunnableExecutedForTests() {
        return mMenuDismissedRunnableExecuted;
    }
}