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}