package com.mopub.nativeads; import android.content.Context; import android.os.Handler; import android.os.SystemClock; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.mopub.common.VisibleForTesting; import java.util.ArrayList; import java.util.List; import static com.mopub.nativeads.MoPubNative.MoPubNativeNetworkListener; /** * An ad source responsible for requesting ads from the MoPub ad server. * * The ad source utilizes a cache to store ads, which allows ads to be immediately visible when * scrolling through a stream rather than "snapping" in when loaded. The cache is implemented as * a queue, so that the first ad loaded from the server will be the first ad available for dequeue. * To take an ad out of the cache, call {@link #dequeueAd}. * * The cache size may be automatically adjusted by the MoPub server based on an app's usage and * ad fill rate. Cached ads have a maximum TTL of 15 minutes before which they expire. * * The ad source also takes care of retrying failed ad requests, with a reasonable back-off to * avoid spamming the server. * * This class is not thread safe and should only be called from the UI thread. */ class NativeAdSource { private static final int CACHE_LIMIT = 3; private static final int EXPIRATION_TIME_MILLISECONDS = 15 * 60 * 1000; // 15 minutes private static final int DEFAULT_RETRY_TIME_MILLISECONDS = 1000; // 1 second private static final int MAXIMUM_RETRY_TIME_MILLISECONDS = 5 * 60 * 1000; // 5 minutes. private static final double EXPONENTIAL_BACKOFF_FACTOR = 2.0; @NonNull private final List<TimestampWrapper<NativeResponse>> mNativeAdCache; @NonNull private final Handler mReplenishCacheHandler; @NonNull private final Runnable mReplenishCacheRunnable; @NonNull private final MoPubNativeNetworkListener mMoPubNativeNetworkListener; @VisibleForTesting boolean mRequestInFlight; @VisibleForTesting boolean mRetryInFlight; @VisibleForTesting int mSequenceNumber; @VisibleForTesting int mRetryTimeMilliseconds; @Nullable private AdSourceListener mAdSourceListener; // We will need collections of these when we support multiple ad units. @Nullable private RequestParameters mRequestParameters; @Nullable private MoPubNative mMoPubNative; /** * A listener for when ads are available for dequeueing. */ interface AdSourceListener { /** * Called when the number of items available for goes from 0 to more than 0. */ void onAdsAvailable(); } NativeAdSource() { this(new ArrayList<TimestampWrapper<NativeResponse>>(CACHE_LIMIT), new Handler()); } @VisibleForTesting NativeAdSource(@NonNull final List<TimestampWrapper<NativeResponse>> nativeAdCache, @NonNull final Handler replenishCacheHandler) { mNativeAdCache = nativeAdCache; mReplenishCacheHandler = replenishCacheHandler; mReplenishCacheRunnable = new Runnable() { @Override public void run() { mRetryInFlight = false; replenishCache(); } }; // Construct native URL and start filling the cache mMoPubNativeNetworkListener = new MoPubNativeNetworkListener() { @Override public void onNativeLoad(@NonNull final NativeResponse nativeResponse) { // This can be null if the ad source was cleared as the AsyncTask is posting // back to the UI handler. Drop this response. if (mMoPubNative == null) { return; } mRequestInFlight = false; mSequenceNumber++; resetRetryTime(); mNativeAdCache.add(new TimestampWrapper<NativeResponse>(nativeResponse)); if (mNativeAdCache.size() == 1 && mAdSourceListener != null) { mAdSourceListener.onAdsAvailable(); } replenishCache(); } @Override public void onNativeFail(final NativeErrorCode errorCode) { // Reset the retry time for the next time we dequeue. mRequestInFlight = false; // Stopping requests after the max retry time prevents us from using battery when // the user is not interacting with the stream, eg. the app is backgrounded. if (mRetryTimeMilliseconds >= MAXIMUM_RETRY_TIME_MILLISECONDS) { resetRetryTime(); return; } updateRetryTime(); mRetryInFlight = true; mReplenishCacheHandler.postDelayed(mReplenishCacheRunnable, mRetryTimeMilliseconds); } }; mSequenceNumber = 0; mRetryTimeMilliseconds = DEFAULT_RETRY_TIME_MILLISECONDS; } /** * Sets a adSourceListener for determining when ads are available. * @param adSourceListener An AdSourceListener. */ void setAdSourceListener(@Nullable final AdSourceListener adSourceListener) { mAdSourceListener = adSourceListener; } void loadAds(@NonNull final Context context, @NonNull final String adUnitId, final RequestParameters requestParameters) { loadAds(requestParameters, new MoPubNative(context, adUnitId, mMoPubNativeNetworkListener)); } @VisibleForTesting void loadAds(final RequestParameters requestParameters, final MoPubNative moPubNative) { clear(); mRequestParameters = requestParameters; mMoPubNative = moPubNative; replenishCache(); } /** * Clears the ad source, removing any currently queued ads. */ void clear() { // This will cleanup listeners to stop callbacks from handling old ad units if (mMoPubNative != null) { mMoPubNative.destroy(); mMoPubNative = null; } mRequestParameters = null; for (final TimestampWrapper<NativeResponse> timestampWrapper : mNativeAdCache) { timestampWrapper.mInstance.destroy(); } mNativeAdCache.clear(); mReplenishCacheHandler.removeMessages(0); mRequestInFlight = false; mSequenceNumber = 0; resetRetryTime(); } /** * Removes an ad from the front of the ad source cache. * * Dequeueing will automatically attempt to replenish the cache. Callers should dequeue ads as * late as possible, typically immediately before rendering them into a view. * * Set the listener to {@code null} to remove the listener. * * @return Ad ad item that should be rendered into a view. */ @Nullable NativeResponse dequeueAd() { final long now = SystemClock.uptimeMillis(); // Starting an ad request takes several millis. Post for performance reasons. if (!mRequestInFlight && !mRetryInFlight) { mReplenishCacheHandler.post(mReplenishCacheRunnable); } // Dequeue the first ad that hasn't expired. while (!mNativeAdCache.isEmpty()) { TimestampWrapper<NativeResponse> responseWrapper = mNativeAdCache.remove(0); if (now - responseWrapper.mCreatedTimestamp < EXPIRATION_TIME_MILLISECONDS) { return responseWrapper.mInstance; } } return null; } @VisibleForTesting void updateRetryTime() { // Backoff time calculations mRetryTimeMilliseconds = (int) (mRetryTimeMilliseconds * EXPONENTIAL_BACKOFF_FACTOR); if (mRetryTimeMilliseconds > MAXIMUM_RETRY_TIME_MILLISECONDS) { mRetryTimeMilliseconds = MAXIMUM_RETRY_TIME_MILLISECONDS; } } @VisibleForTesting void resetRetryTime() { mRetryTimeMilliseconds = DEFAULT_RETRY_TIME_MILLISECONDS; } /** * Replenish ads in the ad source cache. * * Calling this method is useful for warming the cache without dequeueing an ad. */ @VisibleForTesting void replenishCache() { if (!mRequestInFlight && mMoPubNative != null && mNativeAdCache.size() < CACHE_LIMIT) { mRequestInFlight = true; mMoPubNative.makeRequest(mRequestParameters, mSequenceNumber); } } @Deprecated @VisibleForTesting void setMoPubNative(final MoPubNative moPubNative) { mMoPubNative = moPubNative; } @NonNull @Deprecated @VisibleForTesting MoPubNativeNetworkListener getMoPubNativeNetworkListener() { return mMoPubNativeNetworkListener; } }