package com.mopub.nativeads; import android.app.Activity; import android.content.Context; import android.graphics.Rect; import android.os.Handler; import android.os.SystemClock; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.view.View; import android.view.ViewTreeObserver; import com.mopub.common.VisibleForTesting; import com.mopub.common.logging.MoPubLog; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import static android.view.ViewTreeObserver.OnPreDrawListener; /** * Tracks views to determine when they become visible or invisible, where visibility is defined as * having been at least X% on the screen. */ class VisibilityTracker { // Time interval to use for throttling visibility checks. private static final int VISIBILITY_THROTTLE_MILLIS = 100; // Trim the tracked views after this many accesses. This protects us against tracking // too many views if the developer uses the adapter for multiple ListViews. It also // limits the memory leak if a developer forgets to call destroy(). @VisibleForTesting static final int NUM_ACCESSES_BEFORE_TRIMMING = 50; // Temporary array of trimmed views so that we don't allocate this on every trim. @NonNull private final ArrayList<View> mTrimmedViews; // Incrementing access counter. Use a long to support very long-lived apps. private long mAccessCounter = 0; // Listener that passes all visible and invisible views when a visibility check occurs static interface VisibilityTrackerListener { void onVisibilityChanged(List<View> visibleViews, List<View> invisibleViews); } @Nullable @VisibleForTesting OnPreDrawListener mOnPreDrawListener; @NonNull @VisibleForTesting final WeakReference<View> mRootView; static class TrackingInfo { int mMinViewablePercent; long mAccessOrder; } // Views that are being tracked, mapped to the min viewable percentage @NonNull private final Map<View, TrackingInfo> mTrackedViews; // Object to check actual visibility @NonNull private final VisibilityChecker mVisibilityChecker; // Callback listener @Nullable private VisibilityTrackerListener mVisibilityTrackerListener; // Runnable to run on each visibility loop @NonNull private final VisibilityRunnable mVisibilityRunnable; // Handler for visibility @NonNull private final Handler mVisibilityHandler; // Whether the visibility runnable is scheduled private boolean mIsVisibilityScheduled; public VisibilityTracker(@NonNull final Context context) { this(context, new WeakHashMap<View, TrackingInfo>(10), new VisibilityChecker(), new Handler()); } @VisibleForTesting VisibilityTracker(@NonNull final Context context, @NonNull final Map<View, TrackingInfo> trackedViews, @NonNull final VisibilityChecker visibilityChecker, @NonNull final Handler visibilityHandler) { mTrackedViews = trackedViews; mVisibilityChecker = visibilityChecker; mVisibilityHandler = visibilityHandler; mVisibilityRunnable = new VisibilityRunnable(); mTrimmedViews = new ArrayList<View>(NUM_ACCESSES_BEFORE_TRIMMING); final View rootView = ((Activity) context).getWindow().getDecorView(); mRootView = new WeakReference<View>(rootView); final ViewTreeObserver viewTreeObserver = rootView.getViewTreeObserver(); if (!viewTreeObserver.isAlive()) { MoPubLog.w("Visibility Tracker was unable to track views because the" + " root view tree observer was not alive"); } else { mOnPreDrawListener = new OnPreDrawListener() { @Override public boolean onPreDraw() { scheduleVisibilityCheck(); return true; } }; viewTreeObserver.addOnPreDrawListener(mOnPreDrawListener); } } void setVisibilityTrackerListener( @Nullable final VisibilityTrackerListener visibilityTrackerListener) { mVisibilityTrackerListener = visibilityTrackerListener; } /** * Tracks the given view for visibility. */ void addView(@NonNull final View view, final int minPercentageViewed) { // Find the view if already tracked TrackingInfo trackingInfo = mTrackedViews.get(view); if (trackingInfo == null) { trackingInfo = new TrackingInfo(); mTrackedViews.put(view, trackingInfo); scheduleVisibilityCheck(); } trackingInfo.mMinViewablePercent = minPercentageViewed; trackingInfo.mAccessOrder = mAccessCounter; // Trim the number of tracked views to a reasonable number mAccessCounter++; if (mAccessCounter % NUM_ACCESSES_BEFORE_TRIMMING == 0) { trimTrackedViews(mAccessCounter - NUM_ACCESSES_BEFORE_TRIMMING); } } private void trimTrackedViews(long minAccessOrder) { // Clear anything that is below minAccessOrder. for (final Map.Entry<View, TrackingInfo> entry : mTrackedViews.entrySet()) { if (entry.getValue().mAccessOrder < minAccessOrder) { mTrimmedViews.add(entry.getKey()); } } for (View view : mTrimmedViews) { removeView(view); } mTrimmedViews.clear(); } /** * Stops tracking a view, cleaning any pending tracking */ void removeView(@NonNull final View view) { mTrackedViews.remove(view); } /** * Immediately clear all views. Useful for when we re-request ads for an ad placer */ void clear() { mTrackedViews.clear(); mVisibilityHandler.removeMessages(0); mIsVisibilityScheduled = false; } /** * Destroy the visibility tracker, preventing it from future use. */ void destroy() { clear(); final View rootView = mRootView.get(); if (rootView != null && mOnPreDrawListener != null) { final ViewTreeObserver viewTreeObserver = rootView.getViewTreeObserver(); if (viewTreeObserver.isAlive()) { viewTreeObserver.removeOnPreDrawListener(mOnPreDrawListener); } mOnPreDrawListener = null; } mVisibilityTrackerListener = null; } void scheduleVisibilityCheck() { // Tracking this directly instead of calling hasMessages directly because we measured that // this led to slightly better performance. if (mIsVisibilityScheduled) { return; } mIsVisibilityScheduled = true; mVisibilityHandler.postDelayed(mVisibilityRunnable, VISIBILITY_THROTTLE_MILLIS); } class VisibilityRunnable implements Runnable { // Set of views that are visible or invisible. We create these once to avoid excessive // garbage collection observed when calculating these on each pass. @NonNull private final ArrayList<View> mVisibleViews; @NonNull private final ArrayList<View> mInvisibleViews; VisibilityRunnable() { mInvisibleViews = new ArrayList<View>(); mVisibleViews = new ArrayList<View>(); } @Override public void run() { mIsVisibilityScheduled = false; for (final Map.Entry<View, TrackingInfo> entry : mTrackedViews.entrySet()) { final View view = entry.getKey(); final int minPercentageViewed = entry.getValue().mMinViewablePercent; if (mVisibilityChecker.isVisible(view, minPercentageViewed)) { mVisibleViews.add(view); } else { mInvisibleViews.add(view); } } if (mVisibilityTrackerListener != null) { mVisibilityTrackerListener.onVisibilityChanged(mVisibleViews, mInvisibleViews); } // Clear these immediately so that we don't leak memory mVisibleViews.clear(); mInvisibleViews.clear(); } } static class VisibilityChecker { // A rect to use for hit testing. Create this once to avoid excess garbage collection private final Rect mClipRect = new Rect(); /** * Whether the visible time has elapsed from the start time. Easily mocked for testing. */ boolean hasRequiredTimeElapsed(final long startTimeMillis, final int minTimeViewed) { return SystemClock.uptimeMillis() - startTimeMillis >= minTimeViewed; } /** * Whether the view is at least certain % visible */ boolean isVisible(@Nullable final View view, final int minPercentageViewed) { // ListView & GridView both call detachFromParent() for views that can be recycled for // new data. This is one of the rare instances where a view will have a null parent for // an extended period of time and will not be the main window. // view.getGlobalVisibleRect() doesn't check that case, so if the view has visibility // of View.VISIBLE but has no parent it is likely in the recycle bin of a // ListView / GridView and not on screen. if (view == null || view.getVisibility() != View.VISIBLE || view.getParent() == null) { return false; } if (!view.getGlobalVisibleRect(mClipRect)) { // Not visible return false; } // % visible check - the cast is to avoid int overflow for large views. final long visibleViewArea = (long) mClipRect.height() * mClipRect.width(); final long totalViewArea = (long) view.getHeight() * view.getWidth(); if (totalViewArea <= 0) { return false; } return 100 * visibleViewArea >= minPercentageViewed * totalViewArea; } } }