001package com.intentsoftware.addapptr;
002
003import android.app.Application;
004import android.content.pm.ApplicationInfo;
005import android.content.pm.PackageManager;
006import android.os.Bundle;
007import android.os.Handler;
008import android.util.Log;
009import android.util.Pair;
010
011import com.intentsoftware.addapptr.ad.Ad;
012import com.intentsoftware.addapptr.module.Logger;
013
014import java.util.ArrayList;
015import java.util.LinkedList;
016import java.util.List;
017import java.util.Queue;
018
019/**
020 * A cache of automatically preloaded banner ads. The cache will always try to have a defined amount of banners available for immediate handout to the app whenever they are needed.
021 * <b>The BannerCache needs to be destroyed when no longer needed.</b>
022 */
023final public class BannerCache {
024
025    private static final String BANNER_CACHE_RELOAD_INTERVAL_METADATA_TAG = "com.intentsoftware.addapptr.banner_cache_reload_interval";
026    private static final int ON_FAILED_BANNER_RELOAD_INTERVAL_DEFAULT = 10000;
027    private static int onFailedBannerReloadInterval = ON_FAILED_BANNER_RELOAD_INTERVAL_DEFAULT;
028
029    private final BannerCacheConfiguration configuration;
030    private final BannerPlacement bannerPlacement;
031    private final Handler handler = new Handler();
032
033    private final Queue<BannerPlacementLayout> loadedBanners = new LinkedList<>();
034    private final List<BannerRequest> runningRequests = new ArrayList<>();
035    private final List<Runnable> bannerReloadRunnables = new ArrayList<>(); //list for runnables for retrying failed banner requests
036
037    private long consumptionTimestamp;
038
039    private boolean destroyed;
040    private boolean shouldNotifyDelegate = true;
041
042    private int noFills; //number of failed requests, resets on successful load
043
044    //called by AdController, as we need an Context instance
045    static void init(Application application) {
046        try {
047            ApplicationInfo ai = application.getPackageManager().getApplicationInfo(application.getPackageName(), PackageManager.GET_META_DATA);
048            Bundle bundle = ai.metaData;
049            int reloadInterval = bundle.getInt(BANNER_CACHE_RELOAD_INTERVAL_METADATA_TAG);
050            if (reloadInterval > 0) {
051                if (Logger.isLoggable(Log.VERBOSE)) {
052                    Logger.v(BannerCache.class, "Found value:" + reloadInterval + " for BannerCache banner reload interval.");
053                }
054                onFailedBannerReloadInterval = reloadInterval * 1000;
055            } else {
056                if (Logger.isLoggable(Log.VERBOSE)) {
057                    Logger.v(BannerCache.class, "No value for BannerCache banner reload interval found.");
058                }
059            }
060        } catch (Exception e) {
061            if (Logger.isLoggable(Log.WARN)) {
062                Logger.w(BannerCache.class, "Exception when looking for BannerCache banner reload interval in manifest", e);
063            }
064        }
065    }
066
067    //region Public API
068
069    /**
070     * Creates a cache of automatically preloaded banner ads.
071     *
072     * @param cacheConfiguration {@link BannerCacheConfiguration} instance used to configure the cache.
073     */
074    public BannerCache(BannerCacheConfiguration cacheConfiguration) {
075        this.configuration = new BannerCacheConfiguration(cacheConfiguration);
076
077        BannerConfiguration configuration = new BannerConfiguration();
078        configuration.setManualAdSpaceCounting(true);
079        Pair<BannerPlacement, Boolean> placementBooleanPair = AATKit.createBannerPlacementForCache(cacheConfiguration.getPlacementName(), configuration, this);
080
081        bannerPlacement = placementBooleanPair.first;
082
083        if (bannerPlacement == null) {
084            if (Logger.isLoggable(Log.ERROR)) {
085                Logger.e(this, "Failed to create banner placement with name:" + cacheConfiguration.getPlacementName() + " for banner cache:" + toString() + ", cache will not work.");
086            }
087        } else if (Logger.isLoggable(Log.VERBOSE)) {
088            Logger.v(this, "Created banner cache:" + toString() + " with placement name:" + cacheConfiguration.getPlacementName());
089        }
090
091        if (placementBooleanPair.second) {
092            onResume();
093        }
094    }
095
096    /**
097     * Updates the configuration that will be used when requesting new banners.
098     *
099     * @param requestConfiguration {@link BannerRequest} instance. Can not be null.
100     * @param shouldRefresh        True if the whole cache should be re-loaded with new banner request configuration, false if new configuration should only be used for new requests.
101     */
102    @SuppressWarnings("unused")
103    public void updateRequestConfiguration(BannerRequest requestConfiguration, boolean shouldRefresh) {
104        if (checkDestroyedOrFailedToInitialize("updateRequestConfiguration")) return;
105        if (Logger.isLoggable(Log.VERBOSE)) {
106            Logger.v(this, "Updating request configuration with configuration:" + requestConfiguration + ", shouldRefresh:" + shouldRefresh + " for cache:" + toString());
107        }
108
109        this.configuration.setRequestConfiguration(requestConfiguration);
110        if (shouldRefresh) {
111            shouldNotifyDelegate = true; //we want to inform the delegate again
112            clearCache();
113            cancelRunningRequests();
114            fillCache();
115        }
116    }
117
118    /**
119     * Returns an instance of {@link BannerPlacementLayout} to be used within the app. Also automatically counts an ad space.
120     * <p>
121     * <b>BannerCache will no longer hold any references to returned banners, and they need to be destroyed manually by the app.</b>
122     * <p>
123     * This method respects the frequency capping, set by {@link BannerCacheConfiguration#setMinimumDelay(int)}
124     *
125     * @return {@link BannerPlacementLayout} instance.
126     */
127    @SuppressWarnings("unused")
128    public synchronized BannerPlacementLayout consume() {
129        return consume(false);
130    }
131
132    /**
133     * Returns an instance of {@link BannerPlacementLayout} to be used within the app. Also automatically counts an ad space.
134     * <b>BannerCache will no longer hold any references to returned banners, and they need to be destroyed manually by the app.</b>
135     *
136     * @param force true if cache should try to return banner ignoring the frequency capping set by {@link BannerCacheConfiguration#setMinimumDelay(int)}
137     * @return {@link BannerPlacementLayout} instance.
138     */
139    public synchronized BannerPlacementLayout consume(boolean force) {
140        if (checkDestroyedOrFailedToInitialize("consume")) return null;
141        if (Logger.isLoggable(Log.VERBOSE)) {
142            Logger.v(this, "Called consume method with param force=" + force);
143        }
144
145        long currentTime = System.currentTimeMillis();
146        if ((currentTime - this.consumptionTimestamp >= configuration.getMinDelayMillis()) || force) {
147            bannerPlacement.countAdSpace();
148            BannerPlacementLayout banner = loadedBanners.poll();
149            if (Logger.isLoggable(Log.VERBOSE)) {
150                Logger.v(this, "Consuming banner from cache:" + toString() + ", returning:" + banner);
151            }
152            fillCache();
153            if (banner != null) {
154                this.consumptionTimestamp = currentTime;
155            }
156            return banner;
157        } else {
158            if (Logger.isLoggable(Log.VERBOSE)) {
159                Logger.v(this, "Minimum delay between \"consume\" calls not reached, returning null");
160            }
161            return null;
162        }
163    }
164
165    /**
166     * Destroys the BannerCache, clearing all preloaded banner ads and canceling pending reload requests. For proper memory management, it needs to be called when the BannerCache is no longer needed.
167     * <b>Destroyed BannerCache can no longer be used.</b>
168     */
169    @SuppressWarnings("unused")
170    public void destroy() {
171        if (checkDestroyedOrFailedToInitialize("destroy")) return;
172        if (Logger.isLoggable(Log.VERBOSE)) {
173            Logger.v(this, "Destroying cache:" + toString());
174        }
175
176        AATKit.destroyBannerCache(this);
177        cancelRunningRequests();
178        clearCache();
179        configuration.setDelegate(null);
180        configuration.setRequestConfiguration(null);
181        destroyed = true;
182    }
183
184    //endregion
185
186    //region lifecycle handling
187
188    void onResume() {
189        noFills = 0; //reset the counter
190        fillCache();
191    }
192
193    void onPause() {
194        cancelRunningRequests();
195    }
196
197    //endregion
198
199    //region private methods
200
201    private boolean checkDestroyedOrFailedToInitialize(String methodName) {
202        if (bannerPlacement == null) {
203            if (Logger.isLoggable(Log.ERROR)) {
204                Logger.e(this, "Cannot execute method " + methodName + ", BannerCache was not created properly.");
205            }
206            return true;
207        } else if (destroyed) {
208            if (Logger.isLoggable(Log.WARN)) {
209                Logger.w(this, "Cannot execute method " + methodName + ", BannerCache is already destroyed!");
210            }
211        }
212        return destroyed;
213    }
214
215    private synchronized void fillCache() {
216        if (!((Placement) bannerPlacement).isActivityResumed()) {
217            if (Logger.isLoggable(Log.DEBUG)) {
218                Logger.d(this, "Cannot fill cache, activity is paused");
219            }
220            return;
221        }
222
223        int adsToLoad = configuration.getSize() - loadedBanners.size() - runningRequests.size() - bannerReloadRunnables.size();
224
225        if (adsToLoad > 0) {
226            if (configuration.shouldCacheAdditionalAdAtStart()) {
227                adsToLoad++;
228                configuration.setShouldCacheAdditionalAdAtStart(false);
229            }
230            if (Logger.isLoggable(Log.VERBOSE)) {
231                Logger.v(this, "Banner cache " + toString() + " will request:" + adsToLoad + " banners.");
232            }
233            for (int i = 0; i < adsToLoad; i++) {
234                requestAd();
235            }
236        }
237    }
238
239    private synchronized void clearCache() {
240        for (BannerPlacementLayout banner : loadedBanners) {
241            banner.destroy();
242        }
243        loadedBanners.clear();
244    }
245
246    private synchronized void cancelRunningRequests() {
247        if (Logger.isLoggable(Log.VERBOSE)) {
248            Logger.v(this, "Canceling running requests");
249        }
250
251        List<BannerRequest> requestsToBeCanceled = new ArrayList<>(runningRequests); //needs a copy, as request.cancel ends up in modifying the list and thus ConcurrentModificationException
252        runningRequests.clear();
253        for (BannerRequest request : requestsToBeCanceled) {
254            bannerPlacement.cancel(request);
255        }
256
257        for (Runnable runnable : bannerReloadRunnables) {
258            handler.removeCallbacks(runnable);
259        }
260        bannerReloadRunnables.clear();
261    }
262
263    private synchronized void handleAdExpired(BannerPlacementLayout layout) {
264        if (Logger.isLoggable(Log.VERBOSE)) {
265            Logger.v(this, "Ad has expired ad will be removed from cache");
266        }
267        loadedBanners.remove(layout);
268        layout.destroy();
269        if (((Placement) bannerPlacement).isActivityResumed()) {
270            requestAd();
271        }
272    }
273
274    private synchronized void requestAd() {
275        if (!((Placement) bannerPlacement).isActivityResumed()) {
276            if (Logger.isLoggable(Log.DEBUG)) {
277                Logger.d(this, "Cannot request ad, activity is paused");
278            }
279            return;
280        }
281
282        if (noFills >= configuration.getSize() && runningRequests.size() + bannerReloadRunnables.size() >= 1) {
283            if (Logger.isLoggable(Log.VERBOSE)) {
284                Logger.v(this, "Ad will not be requested due to number of failed requests:" + noFills + " and reload request already being posted " +
285                        "(Running requests: " + runningRequests.size() + ", reload runnables posted: " + bannerReloadRunnables.size() + ").");
286            }
287            return;
288        }
289
290        BannerRequest newRequest = new BannerRequest(configuration.getRequestConfiguration().getDelegate());
291        newRequest.setBannerSizes(configuration.getRequestConfiguration().getBannerSizes());
292        newRequest.setTargetingInformation(configuration.getRequestConfiguration().getTargetingInformation());
293        newRequest.setContentTargetingUrl(configuration.getRequestConfiguration().getContentTargetingUrl());
294
295        runningRequests.add(newRequest);
296        bannerPlacement.requestAd(newRequest, createRequestCompletionListener(newRequest));
297    }
298
299    private BannerRequestCompletionListener createRequestCompletionListener(final BannerRequest request) {
300        return new BannerRequestCompletionListener() {
301            @Override
302            public void onRequestCompleted(final BannerPlacementLayout layout, BannerRequestError error) {
303                synchronized (BannerCache.this) {
304                    runningRequests.remove(request);
305                    if (layout != null) {
306                        noFills = 0; //reset the counter
307                        if (Logger.isLoggable(Log.VERBOSE)) {
308                            Logger.v(BannerCache.this, "Banner:" + layout + " added to cache:" + BannerCache.this.toString());
309                        }
310                        layout.setExpirationListener(new Ad.ExpirationListener() {
311                            @Override
312                            public void onAdExpired() {
313                                handleAdExpired(layout);
314                            }
315                        });
316                        loadedBanners.add(layout);
317                        if (shouldNotifyDelegate && configuration.getDelegate() != null) {
318                            if (Logger.isLoggable(Log.VERBOSE)) {
319                                Logger.v(BannerCache.this, "Banner cache:" + BannerCache.this.toString() + " notifying delegate:" + configuration.getDelegate());
320                            }
321                            configuration.getDelegate().firstBannerLoaded();
322                        }
323                        shouldNotifyDelegate = false;
324                        fillCache();
325                    } else if (!request.isCancelled()) { //do not treat canceled requests as failure
326                        noFills++;
327                        if (Logger.isLoggable(Log.VERBOSE)) {
328                            Logger.v(BannerCache.this, "Failed to load banner for queue, error:" + error.getMessage());
329                        }
330                        Runnable runnable = new Runnable() {
331                            @Override
332                            public void run() {
333                                synchronized (BannerCache.this) {
334                                    bannerReloadRunnables.remove(this);
335                                    requestAd();
336                                }
337                            }
338                        };
339                        bannerReloadRunnables.add(runnable);
340                        handler.postDelayed(runnable, onFailedBannerReloadInterval); //retry banner reload after given time
341                    }
342                }
343            }
344        };
345    }
346
347
348    //endregion
349
350    @SuppressWarnings("NullableProblems")
351    @Override
352    public String toString() {
353        return "BannerCache{" +
354                "configuration=" + configuration +
355                '}';
356    }
357
358    /**
359     * Optional delegate informing about events in BannerCache.
360     */
361    public interface CacheDelegate {
362        /**
363         * Called when the first banner gets loaded for the cache. Only called once.
364         */
365        void firstBannerLoaded();
366    }
367}