chromium/chrome/browser/feed/android/java/src/org/chromium/chrome/browser/feed/FeedSliceViewTracker.java

// Copyright 2020 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.feed;

import android.app.Activity;
import android.graphics.Rect;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewTreeObserver;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.xsurface.ListLayoutHelper;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;

/**
 * Tracks position of slice views. When a slice's view is first 2/3rds visible in the viewport,
 * the observer is notified.
 */
public class FeedSliceViewTracker implements ViewTreeObserver.OnPreDrawListener {
    private static final float DEFAULT_VIEW_LOG_THRESHOLD = .66f;
    private static final float GOOD_VISITS_EXPOSURE_THRESHOLD = 0.5f;
    private static final float GOOD_VISITS_COVERAGE_THRESHOLD = 0.25f;
    private static final float VISIBLE_CHANGE_LOG_THRESHOLD = 0.05f;

    private class VisibilityObserver {
        final float mVisibilityThreshold;
        final Runnable mCallback;

        VisibilityObserver(float visibilityThreshold, Runnable callback) {
            mVisibilityThreshold = visibilityThreshold;
            mCallback = callback;
        }
    }

    private final Activity mActivity;
    // Whether to watch a slice view to get notified for user-interaction reliability related
    // UI changes.
    private final boolean mWatchForUserInteractionReliabilityReport;
    @Nullable private RecyclerView mRootView;
    @Nullable private FeedListContentManager mContentManager;
    private ListLayoutHelper mLayoutHelper;
    // The set of content keys already reported as mostly visible (66% threshold), which is used to
    // determine if a slice has been viewed by the user.
    private HashSet<String> mContentKeysMostlyVisible = new HashSet<String>();
    // The set of content keys already reported as barely visible (5% threshold), which is used to
    // determine if a slice has entered the view port.
    private HashSet<String> mContentKeysBarelyVisible = new HashSet<>();
    // The set of content keys for load-more indicators already reported as visible (5% threshold).
    private HashSet<String> mLoadMoreIndicatorContentKeys = new HashSet<>();
    // The set of content keys for load-more indicators already reported as that the user scrolled
    // away from the indicator.
    private HashSet<String> mLoadMoreAwayFromIndicatorContentKeys = new HashSet<>();
    private boolean mFeedContentVisible;
    @Nullable private Observer mObserver;
    // Map from content key to a list of watchers that will get notified for the first-time visible
    // changes. Each item in the waicther list consists of the view threshold percentage and the
    // callback.
    private HashMap<String, ArrayList<VisibilityObserver>> mWatchedSliceMap = new HashMap<>();
    // Timestamp for keeping track of time spent in feed for good visits.
    private long mLastGoodVisibleTime;

    /** Notified the first time slices are visible */
    public interface Observer {
        // Invoked the first time a slice is 66% visible.
        void sliceVisible(String sliceId);

        // Invoked any time at least one slice is X% exposed and all visible content slices cover Y%
        // of the viewport (see Good Visits threshold params).
        void reportContentSliceVisibleTime(long elapsedMs);

        // Invoked when feed content is first visible. This can happens as soon as an xsurface view
        // is partially visible.
        void feedContentVisible();

        // For reporting to feed user interaction reliability log.
        //
        // Called the first time a slice view is 5% visible.
        void reportViewFirstBarelyVisible(View view);

        // Called the first time a slice view is rendered.
        void reportViewFirstRendered(View view);

        // Called the first time a loading indicator for load-more is 5% visible.
        void reportLoadMoreIndicatorVisible();

        // Called the first time the user scrolled away from the loading indicator for load-more.
        void reportLoadMoreUserScrolledAwayFromIndicator();
    }

    public FeedSliceViewTracker(
            @NonNull RecyclerView rootView,
            @NonNull Activity activity,
            @NonNull FeedListContentManager contentManager,
            @Nullable ListLayoutHelper layoutHelper,
            boolean watchForUserInteractionReliabilityReport,
            @NonNull Observer observer) {
        mActivity = activity;
        mRootView = rootView;
        mContentManager = contentManager;
        mLayoutHelper = layoutHelper;
        mWatchForUserInteractionReliabilityReport = watchForUserInteractionReliabilityReport;
        mObserver = observer;
    }

    /** Attaches the tracker to the root view. */
    public void bind() {
        mRootView.getViewTreeObserver().addOnPreDrawListener(this);
        mLastGoodVisibleTime = 0L;
    }

    /** Detaches the tracker from the view. */
    public void unbind() {
        if (mRootView != null && mRootView.getViewTreeObserver().isAlive()) {
            mRootView.getViewTreeObserver().removeOnPreDrawListener(this);
        }
        reportTimeForGoodVisitsIfNeeded();
    }

    /** Stop observing rootView. Prevents further calls to observer. */
    public void destroy() {
        unbind();
        mRootView = null;
        mObserver = null;
        mContentManager = null;
        mWatchedSliceMap = null;
        mLayoutHelper = null;
    }

    /** Clear tracking so that slices already seen can be reported as viewed again. */
    public void clear() {
        mContentKeysMostlyVisible.clear();
        mFeedContentVisible = false;
        if (mWatchedSliceMap != null) {
            mWatchedSliceMap.clear();
        }
        mContentKeysBarelyVisible.clear();
    }

    /**
     * Watches a slice view to get notified when the first time it has the visible area on screen
     * not less than the given threshold.
     * @param contentKey The content key of the view to watch for.
     * @param viewedThreshold The threshold of the percentage of the visible area on screen.
     * @param callback The callback to get notified.
     */
    public void watchForFirstVisible(String contentKey, float viewedThreshold, Runnable callback) {
        if (mWatchedSliceMap == null) { // avoid crbug.com/1416344
            return;
        }
        ArrayList<VisibilityObserver> watchers = mWatchedSliceMap.get(contentKey);
        if (watchers == null) {
            watchers = new ArrayList<>();
            mWatchedSliceMap.put(contentKey, watchers);
        }
        watchers.add(new VisibilityObserver(viewedThreshold, callback));
    }

    /**
     * Stops watching a slice view for first-time visible.
     * @param contentKey The content key of the view to stop watching for.
     * @param callback The callback to stop from getting the notification.
     */
    public void stopWatchingForFirstVisible(String contentKey, Runnable callback) {
        if (mWatchedSliceMap == null) { // avoid crbug.com/1416344
            return;
        }
        ArrayList<VisibilityObserver> watchers = mWatchedSliceMap.get(contentKey);
        if (watchers == null) {
            return;
        }
        for (int i = 0; i < watchers.size(); ++i) {
            if (watchers.get(i).mCallback == callback) {
                watchers.remove(i);
                break;
            }
        }
        if (watchers.isEmpty()) {
            mWatchedSliceMap.remove(contentKey);
        }
    }

    // ViewTreeObserver.OnPreDrawListener.
    @Override
    public boolean onPreDraw() {
        // Not sure why, but this method can be called just after destroy().
        if (mRootView == null || mLayoutHelper == null) return true;

        int firstPosition = mLayoutHelper.findFirstVisibleItemPosition();
        int lastPosition = mLayoutHelper.findLastVisibleItemPosition();
        boolean countTimeForGoodVisits = false;
        for (int i = firstPosition;
                i <= lastPosition && i < mContentManager.getItemCount() && i >= 0;
                ++i) {
            String contentKey = mContentManager.getContent(i).getKey();
            View childView = mRootView.getLayoutManager().findViewByPosition(i);
            if (childView == null) continue;

            // Loading spinner slices come with a fixed prefix and a different ID after it.
            if (mWatchForUserInteractionReliabilityReport
                    && contentKey.startsWith("load-more-spinner")) {
                if (!mLoadMoreIndicatorContentKeys.contains(contentKey)
                        && isViewVisible(childView, VISIBLE_CHANGE_LOG_THRESHOLD)) {
                    mLoadMoreIndicatorContentKeys.add(contentKey);
                    mObserver.reportLoadMoreIndicatorVisible();
                }
                if (!mLoadMoreAwayFromIndicatorContentKeys.contains(contentKey)
                        && mLoadMoreIndicatorContentKeys.contains(contentKey)
                        && !isViewVisible(childView, VISIBLE_CHANGE_LOG_THRESHOLD)) {
                    mLoadMoreAwayFromIndicatorContentKeys.add(contentKey);
                    mObserver.reportLoadMoreUserScrolledAwayFromIndicator();
                }
            }

            // Feed content slices come with a 'c/' prefix. Ignore everything else.
            if (!contentKey.startsWith("c/")) continue;

            if (!mFeedContentVisible) {
                mFeedContentVisible = true;
                mObserver.feedContentVisible();
            }

            ArrayList<VisibilityObserver> watchers = mWatchedSliceMap.get(contentKey);
            if (watchers != null) {
                ArrayList<Integer> indexesToRemove = new ArrayList<>();
                ArrayList<Runnable> callbacksToInvoke = new ArrayList<>();
                for (int j = 0; j < watchers.size(); ++j) {
                    VisibilityObserver observer = watchers.get(j);
                    if (isViewVisible(childView, observer.mVisibilityThreshold)) {
                        callbacksToInvoke.add(observer.mCallback);
                        indexesToRemove.add(j);
                    }
                }
                // Remove the indexes before invoking the callbacks in case that some callback may
                // call stopWatchingForFirstVisible.
                for (int j = indexesToRemove.size() - 1; j >= 0; --j) {
                    // Pass int, instead of Integer, to remove the specified index from the list.
                    watchers.remove(indexesToRemove.get(j).intValue());
                }
                if (watchers.isEmpty()) {
                    mWatchedSliceMap.remove(contentKey);
                }
                for (Runnable callback : callbacksToInvoke) {
                    callback.run();
                }
            }

            countTimeForGoodVisits =
                    countTimeForGoodVisits
                            || isViewVisible(childView, GOOD_VISITS_EXPOSURE_THRESHOLD)
                            || isViewCoveringViewport(childView, GOOD_VISITS_COVERAGE_THRESHOLD);

            if (!mContentKeysMostlyVisible.contains(contentKey)
                    && isViewVisible(childView, DEFAULT_VIEW_LOG_THRESHOLD)) {
                mContentKeysMostlyVisible.add(contentKey);
                mObserver.sliceVisible(contentKey);
            }

            if (mWatchForUserInteractionReliabilityReport
                    && !mContentKeysBarelyVisible.contains(contentKey)
                    && isViewVisible(childView, VISIBLE_CHANGE_LOG_THRESHOLD)) {
                mObserver.reportViewFirstBarelyVisible(childView);
                // There is not a system way to measure the render latency. Here we mimic how
                // Time To First Draw Done is measured, which is done by posting a runnable after
                // onPreDraw.
                Runnable renderedRunnable =
                        () -> {
                            if (mObserver != null) {
                                mObserver.reportViewFirstRendered(childView);
                            }
                        };
                PostTask.postTask(TaskTraits.UI_DEFAULT, renderedRunnable);
                mContentKeysBarelyVisible.add(contentKey);
            }
        }

        reportTimeForGoodVisitsIfNeeded();
        if (countTimeForGoodVisits) {
            mLastGoodVisibleTime = SystemClock.elapsedRealtime();
        }

        return true;
    }

    private void reportTimeForGoodVisitsIfNeeded() {
        // Report elapsed time since we last saw that content was visible enough.
        if (mLastGoodVisibleTime != 0L) {
            mObserver.reportContentSliceVisibleTime(
                    SystemClock.elapsedRealtime() - mLastGoodVisibleTime);
            mLastGoodVisibleTime = 0L;
        }
    }

    @VisibleForTesting
    boolean isViewVisible(View childView, float threshold) {
        int viewArea = getViewArea(childView);
        if (viewArea <= 0) return false;
        return (float) getVisibleArea(childView) / viewArea >= threshold;
    }

    @VisibleForTesting
    boolean isViewCoveringViewport(View childView, float threshold) {
        int viewportArea = getViewportArea();
        if (viewportArea <= 0) return false;
        return (float) getVisibleArea(childView) / viewportArea >= threshold;
    }

    private int getViewArea(View childView) {
        return childView.getWidth() * childView.getHeight();
    }

    private int getViewportArea() {
        Rect viewport = new Rect();
        mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(viewport);
        return viewport.width() * viewport.height();
    }

    private int getVisibleArea(View childView) {
        Rect rect = new Rect(0, 0, childView.getWidth(), childView.getHeight());
        if (!mRootView.getChildVisibleRect(childView, rect, null)) return 0;
        return rect.width() * rect.height();
    }
}