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