chromium/content/public/android/java/src/org/chromium/content/browser/accessibility/AccessibilityHistogramRecorder.java

// Copyright 2022 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.content.browser.accessibility;

import android.os.SystemClock;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.TraceEvent;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.ui.accessibility.AccessibilityState;

/** Helper class for recording UMA histograms of accessibility events */
public class AccessibilityHistogramRecorder {
    // OnDemand AX Mode histogram values
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String PERCENTAGE_DROPPED_HISTOGRAM =
            "Accessibility.Android.OnDemand.PercentageDropped";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_COMPLETE =
            "Accessibility.Android.OnDemand.PercentageDropped.Complete";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_FORM_CONTROLS =
            "Accessibility.Android.OnDemand.PercentageDropped.FormControls";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_BASIC =
            "Accessibility.Android.OnDemand.PercentageDropped.Basic";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String EVENTS_DROPPED_HISTOGRAM =
            "Accessibility.Android.OnDemand.EventsDropped";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String ONE_HUNDRED_PERCENT_HISTOGRAM =
            "Accessibility.Android.OnDemand.OneHundredPercentEventsDropped";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_COMPLETE =
            "Accessibility.Android.OnDemand.OneHundredPercentEventsDropped.Complete";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_FORM_CONTROLS =
            "Accessibility.Android.OnDemand.OneHundredPercentEventsDropped.FormControls";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_BASIC =
            "Accessibility.Android.OnDemand.OneHundredPercentEventsDropped.Basic";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String USAGE_FOREGROUND_TIME = "Accessibility.Android.Usage.Foreground";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String USAGE_NATIVE_INITIALIZED_TIME =
            "Accessibility.Android.Usage.NativeInit";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String USAGE_ACCESSIBILITY_ALWAYS_ON_TIME =
            "Accessibility.Android.Usage.A11yAlwaysOn";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_DISABLE_METHOD_CALLED_INITIAL =
            "Accessibility.Android.AutoDisableV2.DisableCalled.Initial";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_DISABLE_METHOD_CALLED_SUCCESSIVE =
            "Accessibility.Android.AutoDisableV2.DisableCalled.Successive";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_REENABLE_METHOD_CALLED_INITIAL =
            "Accessibility.Android.AutoDisableV2.ReEnableCalled.Initial";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_REENABLE_METHOD_CALLED_SUCCESSIVE =
            "Accessibility.Android.AutoDisableV2.ReEnabledCalled.Successive";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_DISABLED_TIME_INITIAL =
            "Accessibility.Android.AutoDisableV2.DisabledTime.Initial";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_DISABLED_TIME_SUCCESSIVE =
            "Accessibility.Android.AutoDisableV2.DisabledTime.Successive";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_ENABLED_TIME_INITIAL =
            "Accessibility.Android.AutoDisableV2.EnabledTime.Initial";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String AUTO_DISABLE_ACCESSIBILITY_ENABLED_TIME_SUCCESSIVE =
            "Accessibility.Android.AutoDisableV2.EnabledTime.Successive";

    private static final int EVENTS_DROPPED_HISTOGRAM_MIN_BUCKET = 1;
    private static final int EVENTS_DROPPED_HISTOGRAM_MAX_BUCKET = 10000;
    private static final int EVENTS_DROPPED_HISTOGRAM_BUCKET_COUNT = 100;

    // Node cache histogram values
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String CACHE_MAX_NODES_HISTOGRAM =
            "Accessibility.Android.Cache.MaxNodesInCache";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public static final String CACHE_PERCENTAGE_RETRIEVED_FROM_CACHE_HISTOGRAM =
            "Accessibility.Android.Cache.PercentageRetrievedFromCache";

    private static final int CACHE_MAX_NODES_MIN_BUCKET = 1;
    private static final int CACHE_MAX_NODES_MAX_BUCKET = 3000;
    private static final int CACHE_MAX_NODES_BUCKET_COUNT = 100;

    // These track the total number of enqueued events, and the total number of dispatched events,
    // so we can report the percentage/number of dropped events.
    private int mTotalEnqueuedEvents;
    private int mTotalDispatchedEvents;

    // These track the usage of the |mNodeInfoCache| to report metrics on the max number of items
    // that were stored in the cache, and the percentage of requests retrieved from the cache.
    private int mMaxNodesInCache;
    private int mNodeWasReturnedFromCache;
    private int mNodeWasCreatedFromScratch;

    // These track the usage in time when a web contents is in the foreground.
    private long mTimeOfFirstShown = -1;
    private long mTimeOfNativeInitialization = -1;
    private long mTimeOfLastDisabledCall = -1;
    private long mOngoingSumOfTimeDisabled;

    /** Record that the Auto-disable Accessibility feature has disabled accessibility. */
    public void onDisableCalled(boolean initialCall) {
        TraceEvent.begin("AccessibilityHistogramRecorder.onDisabledCalled");
        // To disable accessibility, it needs to have been previously initialized.
        assert mTimeOfNativeInitialization > 0
                : "Accessibility onDisabled was called, but accessibility has not been"
                        + " initialized.";
        long now = SystemClock.elapsedRealtime();

        // As we disable accessibility, we want to record how long it had been enabled.
        if (initialCall) {
            RecordHistogram.recordLongTimesHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_ENABLED_TIME_INITIAL,
                    now - mTimeOfNativeInitialization);
            RecordHistogram.recordBooleanHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_DISABLE_METHOD_CALLED_INITIAL, true);
        } else {
            RecordHistogram.recordLongTimesHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_ENABLED_TIME_SUCCESSIVE,
                    now - mTimeOfNativeInitialization);
            RecordHistogram.recordBooleanHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_DISABLE_METHOD_CALLED_SUCCESSIVE, true);
        }

        // To track how long we kept accessibility disabled if it is eventually re-enabled, track
        // when this call occurred.
        mTimeOfLastDisabledCall = now;

        // Record native initialized time in the usual method so this timeframe is not missed.
        RecordHistogram.recordLongTimesHistogram(
                USAGE_NATIVE_INITIALIZED_TIME, now - mTimeOfNativeInitialization);

        // Reset values.
        mTimeOfNativeInitialization = -1;

        TraceEvent.end("AccessibilityHistogramRecorder.onDisabledCalled");
    }

    /** Record that the Auto-disable Accessibility feature has re-enabled accessibility. */
    public void onReEnableCalled(boolean initialCall) {
        TraceEvent.begin("AccessibilityHistogramRecorder.onReEnabledCalled");
        long now = SystemClock.elapsedRealtime();

        // As we re-enable accessibility, we want to record how long it had been disabled.
        if (initialCall) {
            RecordHistogram.recordLongTimesHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_DISABLED_TIME_INITIAL,
                    (now - mTimeOfLastDisabledCall) + mOngoingSumOfTimeDisabled);
            RecordHistogram.recordBooleanHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_REENABLE_METHOD_CALLED_INITIAL, true);
        } else {
            RecordHistogram.recordLongTimesHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_DISABLED_TIME_SUCCESSIVE,
                    (now - mTimeOfLastDisabledCall) + mOngoingSumOfTimeDisabled);
            RecordHistogram.recordBooleanHistogram(
                    AUTO_DISABLE_ACCESSIBILITY_REENABLE_METHOD_CALLED_SUCCESSIVE, true);
        }

        // To track how long we kept accessibility re-enabled if it is eventually disabled again,
        // track when this call occurred.
        mTimeOfNativeInitialization = now;

        // Reset value.
        mTimeOfLastDisabledCall = -1;
        mOngoingSumOfTimeDisabled = 0;

        TraceEvent.end("AccessibilityHistogramRecorder.onReEnabledCalled");
    }

    /** Increment the count of enqueued events */
    public void incrementEnqueuedEvents() {
        mTotalEnqueuedEvents++;
    }

    /** Increment the count of dispatched events */
    public void incrementDispatchedEvents() {
        mTotalDispatchedEvents++;
    }

    /**
     * Update the value of max nodes in the cache given the current size of the node info cache
     * @param nodeInfoCacheSize the size of the node info cache
     */
    public void updateMaxNodesInCache(int nodeInfoCacheSize) {
        mMaxNodesInCache = Math.max(mMaxNodesInCache, nodeInfoCacheSize);
    }

    /** Increment the count of instances when a node was returned from the cache */
    public void incrementNodeWasReturnedFromCache() {
        mNodeWasReturnedFromCache++;
    }

    /** Increment the count of instances when a node was created from scratch */
    public void incrementNodeWasCreatedFromScratch() {
        mNodeWasCreatedFromScratch++;
    }

    /** Set the time this instance was shown to the current time in ms. */
    public void updateTimeOfFirstShown() {
        mTimeOfFirstShown = SystemClock.elapsedRealtime();
    }

    /** Set the time this instance had native initialization called to the current time in ms. */
    public void updateTimeOfNativeInitialization() {
        mTimeOfNativeInitialization = SystemClock.elapsedRealtime();
    }

    /** Notify the recorder that this instance was shown, and has previously been auto-disabled. */
    public void showAutoDisabledInstance() {
        mTimeOfLastDisabledCall = SystemClock.elapsedRealtime();
    }

    /** Notify the recorder that this instance was hidden, and is currently auto-disabled. */
    public void hideAutoDisabledInstance() {
        mOngoingSumOfTimeDisabled += SystemClock.elapsedRealtime() - mTimeOfLastDisabledCall;
    }

    /** Record UMA histograms for performance-related accessibility metrics. */
    public void recordAccessibilityPerformanceHistograms() {
        // Always track the histograms for events and cache usage statistics.
        recordEventsHistograms();
        recordCacheHistograms();
    }

    /** Record UMA histograms for the event counts for the OnDemand feature. */
    public void recordEventsHistograms() {
        // There are only 2 AXModes, kAXModeComplete is used when a screenreader is active.
        boolean isAXModeComplete = AccessibilityState.isScreenReaderEnabled();
        boolean isAXModeFormControls = AccessibilityState.isOnlyPasswordManagersEnabled();

        // If we did not enqueue any events, we can ignore the data as a trivial case.
        if (mTotalEnqueuedEvents > 0) {
            // Log the percentage dropped (dispatching 0 events should be 100% dropped).
            int percentSent = (int) (mTotalDispatchedEvents * 1.0 / mTotalEnqueuedEvents * 100.0);
            RecordHistogram.recordPercentageHistogram(
                    PERCENTAGE_DROPPED_HISTOGRAM, 100 - percentSent);
            RecordHistogram.recordPercentageHistogram(
                    isAXModeComplete
                            ? PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_COMPLETE
                            : isAXModeFormControls
                                    ? PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_FORM_CONTROLS
                                    : PERCENTAGE_DROPPED_HISTOGRAM_AXMODE_BASIC,
                    100 - percentSent);

            // Log the total number of dropped events. (Not relevant to be tracked per AXMode)
            RecordHistogram.recordCustomCountHistogram(
                    EVENTS_DROPPED_HISTOGRAM,
                    mTotalEnqueuedEvents - mTotalDispatchedEvents,
                    EVENTS_DROPPED_HISTOGRAM_MIN_BUCKET,
                    EVENTS_DROPPED_HISTOGRAM_MAX_BUCKET,
                    EVENTS_DROPPED_HISTOGRAM_BUCKET_COUNT);

            // If 100% of events were dropped, also track the number of dropped events in a
            // separate bucket.
            if (percentSent == 0) {
                RecordHistogram.recordCustomCountHistogram(
                        ONE_HUNDRED_PERCENT_HISTOGRAM,
                        mTotalEnqueuedEvents - mTotalDispatchedEvents,
                        EVENTS_DROPPED_HISTOGRAM_MIN_BUCKET,
                        EVENTS_DROPPED_HISTOGRAM_MAX_BUCKET,
                        EVENTS_DROPPED_HISTOGRAM_BUCKET_COUNT);

                RecordHistogram.recordCustomCountHistogram(
                        isAXModeComplete
                                ? ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_COMPLETE
                                : isAXModeFormControls
                                        ? ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_FORM_CONTROLS
                                        : ONE_HUNDRED_PERCENT_HISTOGRAM_AXMODE_BASIC,
                        mTotalEnqueuedEvents - mTotalDispatchedEvents,
                        EVENTS_DROPPED_HISTOGRAM_MIN_BUCKET,
                        EVENTS_DROPPED_HISTOGRAM_MAX_BUCKET,
                        EVENTS_DROPPED_HISTOGRAM_BUCKET_COUNT);
            }
        }

        // Reset counters.
        mTotalEnqueuedEvents = 0;
        mTotalDispatchedEvents = 0;
    }

    /** Record UMA histograms for the AccessibilityNodeInfo cache usage statistics. */
    public void recordCacheHistograms() {
        RecordHistogram.recordCustomCountHistogram(
                CACHE_MAX_NODES_HISTOGRAM,
                mMaxNodesInCache,
                CACHE_MAX_NODES_MIN_BUCKET,
                CACHE_MAX_NODES_MAX_BUCKET,
                CACHE_MAX_NODES_BUCKET_COUNT);

        int totalNodeRequests = mNodeWasReturnedFromCache + mNodeWasCreatedFromScratch;
        int percentFromCache = (int) (mNodeWasReturnedFromCache * 1.0 / totalNodeRequests * 100.0);

        RecordHistogram.recordPercentageHistogram(
                CACHE_PERCENTAGE_RETRIEVED_FROM_CACHE_HISTOGRAM, percentFromCache);

        // Reset counters.
        mMaxNodesInCache = 0;
        mNodeWasReturnedFromCache = 0;
        mNodeWasCreatedFromScratch = 0;
    }

    /** Record UMA histograms for the usage timers of the native accessibility engine. */
    public void recordAccessibilityUsageHistograms() {
        // If the Tab was not shown, the following histograms have no value.
        if (mTimeOfFirstShown < 0) return;

        long now = SystemClock.elapsedRealtime();

        // On activity recreate, or tab reparent, we can get quick succession of show/hide events,
        // and we do not want to record those, so limit to instances > 250ms.
        if (now - mTimeOfFirstShown < 250 /* ms */) {
            mTimeOfFirstShown = -1;
            return;
        }

        // Record the general usage in the foreground, long histograms are up to 1 hour.
        RecordHistogram.recordLongTimesHistogram(USAGE_FOREGROUND_TIME, now - mTimeOfFirstShown);

        // If native was not initialized, the following histograms have no value. Reset and return.
        if (mTimeOfNativeInitialization < 0) {
            mTimeOfFirstShown = -1;
            return;
        }

        // Record native initialized time, long histograms are up to 1 hour.
        RecordHistogram.recordLongTimesHistogram(
                USAGE_NATIVE_INITIALIZED_TIME, now - mTimeOfNativeInitialization);

        // When the foreground and native usage times are close in value, then we will assume this
        // was an instance with an accessibility service always running, and record that usage.
        long timeDiff = Math.abs(mTimeOfNativeInitialization - mTimeOfFirstShown);
        if (timeDiff < 500 /* ms */
                || ((double) timeDiff / (now - mTimeOfFirstShown)) < 0.03 /* % */) {
            RecordHistogram.recordLongTimesHistogram(
                    USAGE_ACCESSIBILITY_ALWAYS_ON_TIME, now - mTimeOfNativeInitialization);
        }

        // Reset values.
        mTimeOfFirstShown = -1;
    }
}