chromium/chrome/android/java/src/org/chromium/chrome/browser/usage_stats/UsageStatsService.java

// Copyright 2018 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.usage_stats;

import android.app.Activity;
import android.os.Build;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.CollectionUtil;
import org.chromium.base.Log;
import org.chromium.base.Promise;
import org.chromium.base.ThreadUtils;
import org.chromium.base.lifetime.Destroyable;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.AppHooks;
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.tab_ui.TabContentManager;
import org.chromium.components.user_prefs.UserPrefs;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

/**
 * Public interface for all usage stats related functionality. All calls to instances of
 * UsageStatsService must be made on the UI thread.
 */
public class UsageStatsService implements Destroyable {
    private static final String TAG = "UsageStatsService";

    private static ProfileKeyedMap<UsageStatsService> sProfileMap =
            ProfileKeyedMap.createMapOfDestroyables(
                    ProfileKeyedMap.ProfileSelection.REDIRECTED_TO_ORIGINAL);

    private Profile mProfile;
    private EventTracker mEventTracker;
    private SuspensionTracker mSuspensionTracker;
    private TokenTracker mTokenTracker;
    private UsageStatsBridge mBridge;
    // PageViewObservers are scoped to a given ChromeTabbedActivity, but UsageStatsService isn't. To
    // allow for GC of the observer to happen when the activity goes away, we only hold weak
    // references here.
    private List<WeakReference<PageViewObserver>> mPageViewObservers;

    private DigitalWellbeingClient mClient;
    private boolean mOptInState;

    /** Returns if the UsageStatsService is enabled on this device */
    public static boolean isEnabled() {
        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
    }

    /** Return the {@link UsageStatsService} for the given {@link Profile}. */
    public static UsageStatsService getForProfile(Profile profile) {
        assert isEnabled();
        return sProfileMap.getForProfile(profile, UsageStatsService::new);
    }

    /**
     * Creates a UsageStatsService for the given Activity if the feature is enabled.
     *
     * @param activity The activity in which page view events are occurring.
     * @param profile The {@link Profile} associated with the activity.
     * @param activityTabProvider The provider of the active tab for the activity.
     * @param tabContentManagerSupplier Supplier of the current {@link TabContentManager}.
     */
    public static void createPageViewObserverIfEnabled(
            Activity activity,
            Profile profile,
            ActivityTabProvider activityTabProvider,
            Supplier<TabContentManager> tabContentManagerSupplier) {
        if (!isEnabled()) return;

        getForProfile(profile)
                .createPageViewObserver(activity, activityTabProvider, tabContentManagerSupplier);
    }

    @VisibleForTesting
    UsageStatsService(Profile profile) {
        mProfile = profile;
        mBridge = new UsageStatsBridge(mProfile, this);
        mEventTracker = new EventTracker(mBridge);
        mSuspensionTracker = new SuspensionTracker(mBridge, mProfile);
        mTokenTracker = new TokenTracker(mBridge);
        mPageViewObservers = new ArrayList<>();
        mClient = AppHooks.get().createDigitalWellbeingClient();

        mSuspensionTracker
                .getAllSuspendedWebsites()
                .then(
                        (suspendedSites) -> {
                            notifyObserversOfSuspensions(suspendedSites, true);
                        });

        mOptInState = getOptInState();
    }

    @Override
    public void destroy() {
        mBridge.destroy();
    }

    public SuspensionTracker getSuspensionTracker() {
        return mSuspensionTracker;
    }

    /**
     * Create a {@link PageViewObserver} for the given tab model selector and activity.
     * @param activity The activity in which page view events are occurring.
     * @param activityTabProvider The provider of the active tab for the activity.
     * @param tabContentManagerSupplier Supplier of the current {@link TabContentManager}.
     */
    private PageViewObserver createPageViewObserver(
            Activity activity,
            ActivityTabProvider activityTabProvider,
            Supplier<TabContentManager> tabContentManagerSupplier) {
        ThreadUtils.assertOnUiThread();
        PageViewObserver observer =
                new PageViewObserver(
                        activity,
                        activityTabProvider,
                        mEventTracker,
                        mTokenTracker,
                        mSuspensionTracker,
                        tabContentManagerSupplier);
        mPageViewObservers.add(new WeakReference<>(observer));
        return observer;
    }

    /** @return Whether the user has authorized DW to access usage stats data. */
    boolean getOptInState() {
        ThreadUtils.assertOnUiThread();
        return UserPrefs.get(mProfile).getBoolean(Pref.USAGE_STATS_ENABLED);
    }

    /** Sets the user's opt in state. */
    void setOptInState(boolean state) {
        ThreadUtils.assertOnUiThread();
        UserPrefs.get(mProfile).setBoolean(Pref.USAGE_STATS_ENABLED, state);

        if (mOptInState == state) return;
        mOptInState = state;
        mClient.notifyOptInStateChange(mOptInState);

        if (!state) {
            getAllSuspendedWebsitesAsync()
                    .then(
                            (suspendedSites) -> {
                                setWebsitesSuspendedAsync(suspendedSites, false);
                            });
            getAllTrackedTokensAsync()
                    .then(
                            (tokens) -> {
                                for (String token : tokens) stopTrackingTokenAsync(token);
                            });
        }

        @UsageStatsMetricsEvent
        int event = state ? UsageStatsMetricsEvent.OPT_IN : UsageStatsMetricsEvent.OPT_OUT;
        UsageStatsMetricsReporter.reportMetricsEvent(event);
    }

    /** Query for all events that occurred in the half-open range [start, end) */
    public Promise<List<WebsiteEvent>> queryWebsiteEventsAsync(long start, long end) {
        ThreadUtils.assertOnUiThread();
        return mEventTracker.queryWebsiteEvents(start, end);
    }

    /** Get all tokens that are currently being tracked. */
    public Promise<List<String>> getAllTrackedTokensAsync() {
        ThreadUtils.assertOnUiThread();
        return mTokenTracker.getAllTrackedTokens();
    }

    /**
     * Start tracking a full-qualified domain name(FQDN), returning the token used to identify it.
     * If the FQDN is already tracked, this will return the existing token.
     */
    public Promise<String> startTrackingWebsiteAsync(String fqdn) {
        ThreadUtils.assertOnUiThread();
        return mTokenTracker.startTrackingWebsite(fqdn);
    }

    /**
     * Stops tracking the site associated with the given token.
     * If the token was not associated with a site, this does nothing.
     */
    public Promise<Void> stopTrackingTokenAsync(String token) {
        ThreadUtils.assertOnUiThread();
        return mTokenTracker.stopTrackingToken(token);
    }

    /** Suspend or unsuspend every site in FQDNs, depending on the value of {@code suspended}. */
    public Promise<Void> setWebsitesSuspendedAsync(List<String> fqdns, boolean suspended) {
        ThreadUtils.assertOnUiThread();
        notifyObserversOfSuspensions(fqdns, suspended);

        return mSuspensionTracker.setWebsitesSuspended(fqdns, suspended);
    }

    /** @return all the sites that are currently suspended. */
    public Promise<List<String>> getAllSuspendedWebsitesAsync() {
        ThreadUtils.assertOnUiThread();
        return mSuspensionTracker.getAllSuspendedWebsites();
    }

    public void onAllHistoryDeleted() {
        ThreadUtils.assertOnUiThread();
        UsageStatsMetricsReporter.reportMetricsEvent(UsageStatsMetricsEvent.CLEAR_ALL_HISTORY);
        mClient.notifyAllHistoryCleared();
        mEventTracker
                .clearAll()
                .except(
                        (exception) -> {
                            // Retry once; if the subsequent attempt fails, log the failure and move
                            // on.
                            mEventTracker
                                    .clearAll()
                                    .except(
                                            (exceptionInner) -> {
                                                Log.e(
                                                        TAG,
                                                        "Failed to clear all events for history"
                                                                + " deletion");
                                            });
                        });
    }

    public void onHistoryDeletedInRange(long startTimeMs, long endTimeMs) {
        ThreadUtils.assertOnUiThread();
        UsageStatsMetricsReporter.reportMetricsEvent(UsageStatsMetricsEvent.CLEAR_HISTORY_RANGE);
        // endTimeMs could be Long.MAX_VALUE, which doesn't play well when converting into a
        // Timestamp proto. It doesn't make any sense to delete into the future, so we can
        // reasonably cap endTimeMs at now.
        long effectiveEndTimeMs = Math.min(endTimeMs, System.currentTimeMillis());
        mClient.notifyHistoryDeletion(startTimeMs, effectiveEndTimeMs);
        mEventTracker
                .clearRange(startTimeMs, effectiveEndTimeMs)
                .except(
                        (exception) -> {
                            // Retry once; if the subsequent attempt fails, log the failure and move
                            // on.
                            mEventTracker
                                    .clearRange(startTimeMs, endTimeMs)
                                    .except(
                                            (exceptionInner) -> {
                                                Log.e(
                                                        TAG,
                                                        "Failed to clear range of events for"
                                                                + " history deletion");
                                            });
                        });
    }

    public void onHistoryDeletedForDomains(List<String> fqdns) {
        ThreadUtils.assertOnUiThread();
        UsageStatsMetricsReporter.reportMetricsEvent(UsageStatsMetricsEvent.CLEAR_HISTORY_DOMAIN);
        mClient.notifyHistoryDeletion(fqdns);
        mEventTracker
                .clearDomains(fqdns)
                .except(
                        (exception) -> {
                            // Retry once; if the subsequent attempt fails, log the failure and move
                            // on.
                            mEventTracker
                                    .clearDomains(fqdns)
                                    .except(
                                            (exceptionInner) -> {
                                                Log.e(
                                                        TAG,
                                                        "Failed to clear domain events for history"
                                                                + " deletion");
                                            });
                        });
    }

    // The below methods are dummies that are only being retained to avoid breaking the downstream
    // build. TODO(pnoland): remove these once the downstream change that converts to using promises
    // lands.
    public List<WebsiteEvent> queryWebsiteEvents(long start, long end) {
        return new ArrayList<>();
    }

    public List<String> getAllTrackedTokens() {
        return new ArrayList<>();
    }

    public String startTrackingWebsite(String fqdn) {
        return "1";
    }

    public void stopTrackingToken(String token) {
        return;
    }

    public void setWebsitesSuspended(List<String> fqdns, boolean suspended) {
        return;
    }

    public List<String> getAllSuspendedWebsites() {
        return new ArrayList<>();
    }

    private void notifyObserversOfSuspensions(List<String> fqdns, boolean suspended) {
        for (PageViewObserver observer : CollectionUtil.strengthen(mPageViewObservers)) {
            for (String fqdn : fqdns) {
                observer.notifySiteSuspensionChanged(fqdn, suspended);
            }
        }
    }
}