package com.mopub.nativeads; import android.content.Context; import android.os.Handler; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.view.View; import android.view.ViewGroup; import com.mopub.common.Preconditions; import com.mopub.common.Preconditions.NoThrow; import com.mopub.common.VisibleForTesting; import com.mopub.common.logging.MoPubLog; import com.mopub.nativeads.MoPubNativeAdPositioning.MoPubClientPositioning; import com.mopub.nativeads.MoPubNativeAdPositioning.MoPubServerPositioning; import com.mopub.nativeads.PositioningSource.PositioningListener; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.WeakHashMap; /** * @code MoPubStreamAdPlacer facilitates loading ads and placing them into a content stream. * * If you are inserting ads into a ListView, we recommend that you use a {@link MoPubAdAdapter} * instead of this class. * * To start loading ads, call {@link #loadAds}. We recommend passing targeting information to * increase the chance that you show ads that are relevant to your users. * * This class is not intended to be used by multiple threads. All calls should be made from the main * UI thread. */ public class MoPubStreamAdPlacer { /** * Constant representing that the view type for a given position is a regular content item * instead of an ad. */ public static final int CONTENT_VIEW_TYPE = 0; private final static MoPubNativeAdLoadedListener EMPTY_NATIVE_AD_LOADED_LISTENER = new MoPubNativeAdLoadedListener() { @Override public void onAdLoaded(final int position) { } @Override public void onAdRemoved(final int position) { } }; @NonNull private final Context mContext; @NonNull private final Handler mPlacementHandler; @NonNull private final Runnable mPlacementRunnable; @NonNull private final PositioningSource mPositioningSource; @NonNull private final NativeAdSource mAdSource; @NonNull private final ImpressionTracker mImpressionTracker; @NonNull private final HashMap<NativeResponse, WeakReference<View>> mViewMap; @NonNull private final WeakHashMap<View, NativeResponse> mNativeResponseMap; private boolean mHasReceivedPositions; @NonNull private PlacementData mPendingPlacementData; private boolean mHasReceivedAds; private boolean mHasPlacedAds; @NonNull private PlacementData mPlacementData; @Nullable private MoPubAdRenderer mAdRenderer; @Nullable private String mAdUnitId; @NonNull private MoPubNativeAdLoadedListener mAdLoadedListener = EMPTY_NATIVE_AD_LOADED_LISTENER; // The visible range is the range of items which we believe are visible, inclusive. // Placing ads near this range makes for a smoother user experience when scrolling up // or down. private static final int MAX_VISIBLE_RANGE = 100; private int mVisibleRangeStart; private int mVisibleRangeEnd; private int mItemCount; // A buffer around the visible range where we'll place ads if possible. private static final int RANGE_BUFFER = 10; private boolean mNeedsPlacement; /** * Creates a new MoPubStreamAdPlacer object. * * By default, the StreamAdPlacer will contact the server to determine ad positions. If you * wish to hard-code positions in your app, see {@link MoPubStreamAdPlacer(Context, * MoPubClientPositioning)}. * * @param context The activity context. */ public MoPubStreamAdPlacer(@NonNull final Context context) { // MoPubClientPositioning is mutable, so we must take care not to hold a // reference to it that might be subsequently modified by the caller. this(context, MoPubNativeAdPositioning.serverPositioning()); } /** * Creates a new MoPubStreamAdPlacer object, using server positioning. * * @param context The activity context. * @param adPositioning A positioning object for specifying where ads will be placed in your * stream. See {@link MoPubNativeAdPositioning#serverPositioning()}. */ public MoPubStreamAdPlacer(@NonNull final Context context, @NonNull final MoPubServerPositioning adPositioning) { this(context, new NativeAdSource(), new ImpressionTracker(context), new ServerPositioningSource(context)); } /** * Creates a new MoPubStreamAdPlacer object, using client positioning. * * @param context The activity context. * @param adPositioning A positioning object for specifying where ads will be placed in your * stream. See {@link MoPubNativeAdPositioning#clientPositioning()}. */ public MoPubStreamAdPlacer(@NonNull final Context context, @NonNull final MoPubClientPositioning adPositioning) { // MoPubClientPositioning is mutable, so we must take care not to hold a // reference to it that might be subsequently modified by the caller. this(context, new NativeAdSource(), new ImpressionTracker(context), new ClientPositioningSource(adPositioning)); } @VisibleForTesting MoPubStreamAdPlacer(@NonNull final Context context, @NonNull final NativeAdSource adSource, @NonNull final ImpressionTracker impressionTracker, @NonNull final PositioningSource positioningSource) { Preconditions.checkNotNull(context, "context is not allowed to be null"); Preconditions.checkNotNull(adSource, "adSource is not allowed to be null"); Preconditions.checkNotNull(impressionTracker, "impressionTracker is not allowed to be " + "null"); Preconditions.checkNotNull(positioningSource, "positioningSource is not allowed to be " + "null"); mContext = context; mImpressionTracker = impressionTracker; mPositioningSource = positioningSource; mAdSource = adSource; mPlacementData = PlacementData.empty(); mNativeResponseMap = new WeakHashMap<View, NativeResponse>(); mViewMap = new HashMap<NativeResponse, WeakReference<View>>(); mPlacementHandler = new Handler(); mPlacementRunnable = new Runnable() { @Override public void run() { if (!mNeedsPlacement) { return; } placeAds(); mNeedsPlacement = false; } }; mVisibleRangeStart = 0; mVisibleRangeEnd = 0; } /** * Registers an ad renderer to use when displaying ads in your stream. * * This renderer will automatically create and render your view when you call {@link * #getAdView}. If you register a second renderer, it will replace the first, although this * behavior is subject to change in a future SDK version. * * @param adRenderer The ad renderer. */ public void registerAdRenderer(@NonNull final MoPubAdRenderer adRenderer) { if (!NoThrow.checkNotNull(adRenderer, "Cannot register a null adRenderer")) { return; } mAdRenderer = adRenderer; } /** * Sets a listener that will be called after the SDK loads new ads from the server and places * them into your stream. * * The listener will be active between when you call {@link #loadAds} and when you call {@link * #destroy()}. You can also set the listener to {@code null} to remove the listener. * * Note that there is not a one to one correspondence between calls to {@link #loadAds} and this * listener. The SDK will call the listener every time an ad loads. * * @param listener The listener. */ public void setAdLoadedListener(@Nullable final MoPubNativeAdLoadedListener listener) { mAdLoadedListener = (listener == null) ? EMPTY_NATIVE_AD_LOADED_LISTENER : listener; } /** * Start loading ads from the MoPub server. * * We recommend using {@link #loadAds(String, RequestParameters)} instead of this method, in * order to pass targeting information to the server. * * @param adUnitId The ad unit ID to use when loading ads. */ public void loadAds(@NonNull final String adUnitId) { loadAds(adUnitId, /* requestParameters */ null); } /** * Start loading ads from the MoPub server, using the given request targeting information. * * When loading ads, use {@link MoPubNativeAdLoadedListener#onAdLoaded(int)} will be called for * each ad that is added to the stream. * * To refresh ads in your stream, call {@code loadAds} again. When new ads load, they will * replace the current ads in your stream. If you are using {@code MoPubNativeAdLoadedListener} * you will see a call to {@code onAdRemoved} for each of the old ads, followed by a calls to * {@code onAdLoaded}. * * @param adUnitId The ad unit ID to use when loading ads. * @param requestParameters Targeting information to pass to the ad server. */ public void loadAds(@NonNull final String adUnitId, @Nullable final RequestParameters requestParameters) { if (!NoThrow.checkNotNull(adUnitId, "Cannot load ads with a null ad unit ID")) { return; } if (mAdRenderer == null) { MoPubLog.w("You must call registerAdRenderer before loading ads"); return; } mAdUnitId = adUnitId; mHasPlacedAds = false; mHasReceivedPositions = false; mHasReceivedAds = false; mPositioningSource.loadPositions(adUnitId, new PositioningListener() { @Override public void onLoad(@NonNull final MoPubClientPositioning positioning) { handlePositioningLoad(positioning); } @Override public void onFailed() { // This will happen only if positions couldn't be loaded after several tries MoPubLog.d("Unable to show ads because ad positions could not be loaded from " + "the MoPub ad server."); } }); mAdSource.setAdSourceListener(new NativeAdSource.AdSourceListener() { @Override public void onAdsAvailable() { handleAdsAvailable(); } }); mAdSource.loadAds(mContext, adUnitId, requestParameters); } @VisibleForTesting void handlePositioningLoad(@NonNull final MoPubClientPositioning positioning) { PlacementData placementData = PlacementData.fromAdPositioning(positioning); if (mHasReceivedAds) { placeInitialAds(placementData); } else { mPendingPlacementData = placementData; } mHasReceivedPositions = true; } @VisibleForTesting void handleAdsAvailable() { // If we've already placed ads, just notify that we need placement. if (mHasPlacedAds) { notifyNeedsPlacement(); return; } // Otherwise, we may need to place initial ads. if (mHasReceivedPositions) { placeInitialAds(mPendingPlacementData); } mHasReceivedAds = true; } private void placeInitialAds(PlacementData placementData) { // Remove ads that may be present and immediately place ads again. This prevents the UI // from flashing grossly. removeAdsInRange(0, mItemCount); mPlacementData = placementData; placeAds(); mHasPlacedAds = true; } /** * Inserts ads that should appear in the given range. * * By default, the ad placer will place ads withing the first 10 positions in your stream, * according the positions you've specified. You can should use this method as your user scrolls * through your stream to place ads into the currently visible range. * * This method takes advantage of a short-lived in memory ad cache, and will immediately place * any ads from the cache. If there are no ads in the cache, this method will load additional * ads from the server and place them once they are loaded. If you call {@code placeAdsInRange} * again before ads are retrieved from the server, the new ads will show in the new positions * rather than the old positions. * * You can pass any integer as a startPosition and endPosition for the range, including negative * numbers or numbers greater than the current stream item count. The ad placer will only place * ads between 0 and item count. * * @param startPosition The start of the range in which to place ads, inclusive. * @param endPosition The end of the range in which to place ads, exclusive. */ public void placeAdsInRange(final int startPosition, final int endPosition) { mVisibleRangeStart = startPosition; mVisibleRangeEnd = Math.min(endPosition, startPosition + MAX_VISIBLE_RANGE); notifyNeedsPlacement(); } /** * Whether the given position is an ad. * * This will return {@code true} only if there is an ad loaded for this position. You can listen * for ads to load using {@link MoPubNativeAdLoadedListener#onAdLoaded(int)}. * * @param position The position to check for an ad, expressed in terms of the position in the * stream including ads. * @return Whether there is an ad at the given position. */ public boolean isAd(final int position) { return mPlacementData.isPlacedAd(position); } /** * Stops loading ads, immediately clearing any ads currently in the stream. * * This method also stops ads from loading as the user moves through the stream. If you want to * just remove ads but want to continue loading them, call {@link #removeAdsInRange(int, int)}. * * When ads are cleared, {@link MoPubNativeAdLoadedListener#onAdRemoved} will be called for each * ad that is removed from the stream. */ public void clearAds() { removeAdsInRange(0, mItemCount); mAdSource.clear(); } /** * Destroys the ad placer, preventing it from future use. * * You must call this method before the hosting activity for this class is destroyed in order to * avoid a memory leak. Typically you should destroy the adapter in the life-cycle method that * is counterpoint to the method you used to create the adapter. For example, if you created the * adapter in {@code Fragment#onCreateView} you should destroy it in {code * Fragment#onDestroyView}. */ public void destroy() { mPlacementHandler.removeMessages(0); mAdSource.clear(); mImpressionTracker.destroy(); mPlacementData.clearAds(); } /** * Returns an ad data object, or {@code null} if there is no ad at this position. * * This method is useful when implementing your own Adapter using {@code MoPubStreamAdPlacer}. * To avoid worrying about view type, consider using {@link MoPubAdAdapter} instead of this * class. * * @param position The position where to place an ad. * @return An object representing ad data. */ @Nullable public Object getAdData(final int position) { return mPlacementData.getPlacedAd(position); } /** * Gets the ad at the given position, or {@code null} if there is no ad at the given position. * * This method will attempt to reuse the convertView if it is not {@code null}, and will * otherwise create it. See {@link MoPubAdRenderer#createAdView(Context, ViewGroup)}. * * @param position The position where to place an ad. * @param convertView A recycled view into which to render data, or {@code null}. * @param parent The parent that the view will eventually be attached to. * @return The newly placed ad view. */ @Nullable public View getAdView(final int position, @Nullable final View convertView, @Nullable final ViewGroup parent) { final NativeAdData adData = mPlacementData.getPlacedAd(position); if (adData == null) { return null; } final MoPubAdRenderer adRenderer = adData.getAdRenderer(); final View view = (convertView != null) ? convertView : adRenderer.createAdView(mContext, parent); NativeResponse nativeResponse = adData.getAd(); WeakReference<View> mappedViewRef = mViewMap.get(nativeResponse); View mappedView = null; if (mappedViewRef != null) { mappedView = mappedViewRef.get(); } if (!view.equals(mappedView)) { clearNativeResponse(mappedView); clearNativeResponse(view); prepareNativeResponse(nativeResponse, view); //noinspection unchecked adRenderer.renderAdView(view, nativeResponse); } return view; } /** * Removes ads in the given range from [startRange, endRange). * * @param originalStartPosition The start position to clear, expressed as the original content * position before ads were inserted. * @param originalEndPosition The position after end position to clear, expressed as the * original content position before ads were inserted. * @return The number of ads removed. */ public int removeAdsInRange(int originalStartPosition, int originalEndPosition) { int[] positions = mPlacementData.getPlacedAdPositions(); int adjustedStartRange = mPlacementData.getAdjustedPosition(originalStartPosition); int adjustedEndRange = mPlacementData.getAdjustedPosition(originalEndPosition); ArrayList<Integer> removedPositions = new ArrayList<Integer>(); // Traverse in reverse order to make this less error-prone for developers who are removing // views directly from their UI. for (int i = positions.length - 1; i >= 0; --i) { int position = positions[i]; if (position < adjustedStartRange || position >= adjustedEndRange) { continue; } removedPositions.add(position); // Decrement the start range for any removed ads. We don't bother to decrement the end // range, as it is OK if it isn't 100% accurate. if (position < mVisibleRangeStart) { mVisibleRangeStart--; } mItemCount--; } int clearedAdsCount = mPlacementData.clearAdsInRange(adjustedStartRange, adjustedEndRange); for (int position : removedPositions) { mAdLoadedListener.onAdRemoved(position); } return clearedAdsCount; } /** * Returns the number of ad view types that can be placed by this ad placer. The number of * possible ad view types is currently 1, but this is subject to change in future SDK versions. * * @return The number of ad view types. * @see #getAdViewType */ public int getAdViewTypeCount() { return 1; } /** * The ad view type for this position. * * Returns 0 if this is a regular content item. Otherwise, returns a number between 1 and {@link * #getAdViewTypeCount}. * * This method is useful when implementing your own Adapter using {@code MoPubStreamAdPlacer}. * To avoid worrying about view type, consider using {@link MoPubAdAdapter} instead of this * class. * * @param position The stream position. * @return The ad view type. */ public int getAdViewType(final int position) { return isAd(position) ? 1 : CONTENT_VIEW_TYPE; } /** * Returns the original position of an item considering ads in the stream. * * For example if your stream looks like: * * {@code Item0 Ad Item1 Item2 Ad Item3 </code> * * {@code getOriginalPosition(5)} will return {@code 3}. * * @param position The adjusted position. * @return The original position before placing ads. */ public int getOriginalPosition(final int position) { return mPlacementData.getOriginalPosition(position); } /** * Returns the position of an item considering ads in the stream. * * @param originalPosition The original position. * @return The position adjusted by placing ads. */ public int getAdjustedPosition(final int originalPosition) { return mPlacementData.getAdjustedPosition(originalPosition); } /** * Returns the original number of items considering ads in the stream. * * @param count The number of items in the stream. * @return The original number of items before placing ads. */ public int getOriginalCount(final int count) { return mPlacementData.getOriginalCount(count); } /** * Returns the number of items considering ads in the stream. * * @param originalCount The original number of items. * @return The number of items adjusted by placing ads. */ public int getAdjustedCount(final int originalCount) { return mPlacementData.getAdjustedCount(originalCount); } /** * Sets the original number of items in your stream. * * You must call this method so that the placer knows where valid positions are to place ads. * After calling this method, the ad placer will call {@link * MoPubNativeAdLoadedListener#onAdLoaded * (int)} each time an ad is loaded in the stream. * * @param originalCount The original number of items. */ public void setItemCount(final int originalCount) { mItemCount = mPlacementData.getAdjustedCount(originalCount); // If we haven't already placed ads, we'll let ads get placed by the normal loadAds call if (mHasPlacedAds) { notifyNeedsPlacement(); } } /** * Inserts a content row at the given position, adjusting ad positions accordingly. * * Use this method if you are inserting an item into your stream and want to increment ad * positions based on that new item. * * For example if your stream looks like: * * {@code Item0 Ad Item1 Item2 Ad Item3} * * and you insert an item at position 2, your new stream will look like: * * {@code Item0 Ad Item1 Item2 New Item Ad Item3} * * @param originalPosition The position at which to add an item. If you have an adjusted * position, you will need to call {@link #getOriginalPosition} to get this value. */ public void insertItem(final int originalPosition) { mPlacementData.insertItem(originalPosition); } /** * Removes the content row at the given position, adjusting ad positions accordingly. * * Use this method if you are removing an item from your stream and want to decrement ad * positions based on that removed item. * * For example if your stream looks like: * * {@code Item0 Ad Item1 Item2 Ad Item3} * * and you remove an item at position 2, your new stream will look like: * * {@code Item0 Ad Item1 Ad Item3} * * @param originalPosition The position at which to add an item. If you have an adjusted * position, you will need to call {@link #getOriginalPosition} to get this value. */ public void removeItem(final int originalPosition) { mPlacementData.removeItem(originalPosition); } /** * Moves the content row at the given position adjusting ad positions accordingly. * * Use this method if you are moving an item in your stream and want to have ad positions move * as well. * * @param originalPosition The position from which to move an item. If you have an adjusted * position, you will need to call {@link #getOriginalPosition} to get this value. * @param newPosition The new position, also expressed in terms of the original position. */ public void moveItem(final int originalPosition, final int newPosition) { mPlacementData.moveItem(originalPosition, newPosition); } private void notifyNeedsPlacement() { // Avoid posting if this method has already been called. if (mNeedsPlacement) { return; } mNeedsPlacement = true; // Post the placement to happen on the next UI render loop. mPlacementHandler.post(mPlacementRunnable); } /** * Places ads using the current visible range. */ private void placeAds() { // Place ads within the visible range if (!tryPlaceAdsInRange(mVisibleRangeStart, mVisibleRangeEnd)) { return; } // Place ads after the visible range so that user will see an ad if they scroll down. We // don't place an ad before the visible range, because we are trying to be mindful of // changes that will affect scrolling. tryPlaceAdsInRange(mVisibleRangeEnd, mVisibleRangeEnd + RANGE_BUFFER); } /** * Attempts to place ads in the range (start, end], returning false if there is no ad available * to be placed. */ private boolean tryPlaceAdsInRange(final int start, final int end) { int position = start; int lastPosition = end - 1; while (position <= lastPosition && position != PlacementData.NOT_FOUND) { if (position >= mItemCount) { break; } if (mPlacementData.shouldPlaceAd(position)) { if (!tryPlaceAd(position)) { return false; } lastPosition++; } position = mPlacementData.nextInsertionPosition(position); } return true; } /** * Attempts to place an ad at the given position, returning false if there is no ad available to * be placed. */ private boolean tryPlaceAd(final int position) { final NativeResponse adResponse = mAdSource.dequeueAd(); if (adResponse == null) { return false; } final NativeAdData adData = createAdData(position, adResponse); mPlacementData.placeAd(position, adData); mItemCount++; mAdLoadedListener.onAdLoaded(position); return true; } @NonNull private NativeAdData createAdData(final int position, @NonNull final NativeResponse adResponse) { Preconditions.checkNotNull(mAdUnitId); Preconditions.checkNotNull(mAdRenderer); //noinspection ConstantConditions return new NativeAdData(mAdUnitId, mAdRenderer, adResponse); } private void clearNativeResponse(@Nullable final View view) { if (view == null) { return; } mImpressionTracker.removeView(view); final NativeResponse lastNativeResponse = mNativeResponseMap.get(view); if (lastNativeResponse != null) { lastNativeResponse.clear(view); mNativeResponseMap.remove(view); mViewMap.remove(lastNativeResponse); } } private void prepareNativeResponse(@NonNull final NativeResponse nativeResponse, @NonNull final View view) { mViewMap.put(nativeResponse, new WeakReference<View>(view)); mNativeResponseMap.put(view, nativeResponse); if (!nativeResponse.isOverridingImpressionTracker()) { mImpressionTracker.addView(view, nativeResponse); } nativeResponse.prepare(view); } }