chromium/chrome/android/java/src/org/chromium/chrome/browser/usage_stats/PageViewObserver.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.annotation.SuppressLint;
import android.app.Activity;
import android.app.usage.UsageStatsManager;
import android.content.Context;

import androidx.annotation.Nullable;

import org.chromium.base.Log;
import org.chromium.base.TraceEvent;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.tab.CurrentTabObserver;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.SadTab;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab_ui.TabContentManager;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.url.GURL;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * Class that observes url and tab changes in order to track when browsing stops and starts for each
 * visited fully-qualified domain name (FQDN).
 */
@SuppressLint("NewApi")
public class PageViewObserver extends EmptyTabObserver {
    private static final String TAG = "PageViewObserver";

    private final Activity mActivity;
    private final CurrentTabObserver mCurrentTabObserver;
    private final EventTracker mEventTracker;
    private final TokenTracker mTokenTracker;
    private final SuspensionTracker mSuspensionTracker;
    private final Supplier<TabContentManager> mTabContentManagerSupplier;

    private Tab mCurrentTab;
    private String mLastFqdn;

    PageViewObserver(
            Activity activity,
            ObservableSupplier<Tab> tabSupplier,
            EventTracker eventTracker,
            TokenTracker tokenTracker,
            SuspensionTracker suspensionTracker,
            Supplier<TabContentManager> tabContentManagerSupplier) {
        mActivity = activity;
        mEventTracker = eventTracker;
        mTokenTracker = tokenTracker;
        mSuspensionTracker = suspensionTracker;
        mTabContentManagerSupplier = tabContentManagerSupplier;
        mCurrentTabObserver = new CurrentTabObserver(tabSupplier, this, this::activeTabChanged);
        mCurrentTabObserver.triggerWithCurrentTab();
    }

    @Override
    public void onShown(Tab tab, @TabSelectionType int type) {
        if (!tab.isLoading() && !tab.isBeingRestored()) {
            updateUrl(tab.getUrl());
        }
    }

    @Override
    public void onHidden(Tab tab, @TabHidingType int type) {
        updateUrl(null);
    }

    @Override
    public void onUpdateUrl(Tab tab, GURL url) {
        assert tab == mCurrentTab;
        String newFqdn = getValidFqdnOrEmptyString(url);
        // We don't call updateUrl() here to avoid reporting start events for domains
        // that never paint, e.g. link shorteners. We still need to check the SuspendedTab
        // state because a tab that's suspended can't paint, and the user could be
        // navigating away from a suspended domain.
        checkSuspendedTabState(mSuspensionTracker.isWebsiteSuspended(newFqdn), newFqdn);
    }

    @Override
    public void didFirstVisuallyNonEmptyPaint(Tab tab) {
        assert tab == mCurrentTab;

        updateUrl(tab.getUrl());
    }

    @Override
    public void onCrash(Tab tab) {
        updateUrl(null);
    }

    /** Notify PageViewObserver that {@code fqdn} was just suspended or un-suspended. */
    public void notifySiteSuspensionChanged(String fqdn, boolean isSuspended) {
        if (mCurrentTab == null || !mCurrentTab.isInitialized()) return;
        SuspendedTab suspendedTab = SuspendedTab.from(mCurrentTab, mTabContentManagerSupplier);
        if (fqdn.equals(mLastFqdn) || fqdn.equals(suspendedTab.getFqdn())) {
            if (checkSuspendedTabState(isSuspended, fqdn)) {
                reportStop();
            }
        }
    }

    /**
     * Updates our state from the previous url to {@code newUrl}. This can result in any/all of the
     * following:
     * 1. Suspension or un-suspension of mCurrentTab.
     * 2. Reporting a stop event for mLastFqdn.
     * 3. Reporting a start event for the fqdn of {@code newUrl}.
     */
    private void updateUrl(@Nullable GURL newUrl) {
        String newFqdn = getValidFqdnOrEmptyString(newUrl);
        boolean isSameDomain = newFqdn.equals(mLastFqdn);
        boolean isValidProtocol = newUrl != null && UrlUtilities.isHttpOrHttps(newUrl);

        boolean isSuspended = mSuspensionTracker.isWebsiteSuspended(newFqdn);
        boolean didSuspend = checkSuspendedTabState(isSuspended, newFqdn);

        if (mLastFqdn != null && (didSuspend || !isSameDomain)) {
            reportStop();
        }

        if (isValidProtocol && !isSuspended && !isSameDomain) {
            mLastFqdn = newFqdn;
            mEventTracker.addWebsiteEvent(
                    new WebsiteEvent(
                            System.currentTimeMillis(), mLastFqdn, WebsiteEvent.EventType.START));
            reportToPlatformIfDomainIsTracked("reportUsageStart", mLastFqdn);
        }
    }

    /**
     * Hides or shows the SuspendedTab for mCurrentTab, based on:
     * 1. If it is currently shown or hidden
     * 2. Its current fqdn, if any.
     * 3. If fqdn is newly suspended or not.
     * There are really only two important cases; either the SuspendedTab is showing and should be
     * hidden, or it's hidden and should be shown.
     */
    private boolean checkSuspendedTabState(boolean isNewlySuspended, String fqdn) {
        if (mCurrentTab == null) return false;
        SuspendedTab suspendedTab = SuspendedTab.from(mCurrentTab, mTabContentManagerSupplier);
        // We don't need to do anything in situations where the current state matches the desired;
        // i.e. either the suspended tab is already showing with the correct fqdn, or the suspended
        // tab is hidden and should be hidden.
        if (isNewlySuspended && fqdn.equals(suspendedTab.getFqdn())) return false;
        if (!isNewlySuspended && !suspendedTab.isShowing()) return false;

        if (isNewlySuspended) {
            suspendedTab.show(fqdn);
            return true;
        } else {
            suspendedTab.removeIfPresent();
            if (!mCurrentTab.isLoading() && !SadTab.isShowing(mCurrentTab)) {
                mCurrentTab.reload();
            }
        }
        return false;
    }

    private void reportStop() {
        mEventTracker.addWebsiteEvent(
                new WebsiteEvent(
                        System.currentTimeMillis(), mLastFqdn, WebsiteEvent.EventType.STOP));
        reportToPlatformIfDomainIsTracked("reportUsageStop", mLastFqdn);
        mLastFqdn = null;
    }

    private void activeTabChanged(Tab tab) {
        mCurrentTab = tab;
        if (mCurrentTab == null) {
            updateUrl(null);
        } else if (mCurrentTab.isIncognito()) {
            updateUrl(null);
            mCurrentTab.removeObserver(this);
        } else if (!mCurrentTab.isHidden()) {
            // If the newly active tab is hidden, we don't want to check its URL yet; we'll wait
            // until the onShown event fires.
            updateUrl(mCurrentTab.getUrl());
        }
    }

    private void reportToPlatformIfDomainIsTracked(String reportMethodName, String fqdn) {
        mTokenTracker
                .getTokenForFqdn(fqdn)
                .then(
                        (token) -> {
                            if (token == null) return;
                            try (TraceEvent te =
                                    TraceEvent.scoped(
                                            "PageViewObserver.reportToPlatformIfDomainIsTracked")) {
                                UsageStatsManager instance =
                                        (UsageStatsManager)
                                                mActivity.getSystemService(
                                                        Context.USAGE_STATS_SERVICE);
                                Method reportMethod =
                                        UsageStatsManager.class.getDeclaredMethod(
                                                reportMethodName, Activity.class, String.class);

                                reportMethod.invoke(instance, mActivity, token);
                            } catch (InvocationTargetException
                                    | NoSuchMethodException
                                    | IllegalAccessException e) {
                                Log.e(TAG, "Failed to report to platform API", e);
                            }
                        });
    }

    private static String getValidFqdnOrEmptyString(GURL url) {
        if (GURL.isEmptyOrInvalid(url)) return "";
        return url.getHost();
    }
}