Skip to content

Commit c633fbc

Browse files
committed
Release v3.0.0: Ad Pool System, Native Fallbacks, Enhanced Analytics
## AdManager & Core - Implement Ad Pool system (`loadMultipleAdUnits`, `getAnyAvailableAd`) to store multiple ads and maximize show rates - Add session-level Firebase tracking (`logAdRequest`, `logAdFill`) and `getAdStats()` for fill/show rate analysis - Add duplicate load prevention logic for concurrent requests - Migrate immersive mode UI to `WindowInsetsController` (replace deprecated `systemUiVisibility`) ## Native Ads - Add `FRESH_WITH_CACHE_FALLBACK` loading strategy (Try fresh → Fallback to cache) - Implement Cross-Ad-Unit Fallback system controlled by `enableCrossAdUnitFallback` - Add `MEDIUM_HORIZONTAL` template (55% media / 45% content split) - Add thread-safe temporary cache for concurrent loading scenarios ## App Open Ads - Add `prefetchNextAd()` to preload ads before launching external intents - Add `isAdLoading()` state check to prevent concurrent fetches ## Build & Maintenance - Bump library version to v3.0.0 - Bump MinSDK 23 → 24 across all modules - Update AGP 8.13.0 → 8.13.1 - Add RELEASE_NOTES_v3.0.0.md
1 parent a4b3d6d commit c633fbc

28 files changed

+1866
-210
lines changed

AdManageKit/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ android {
1010
compileSdk = 36
1111

1212
defaultConfig {
13-
minSdk = 23
13+
minSdk = 24
1414

1515
consumerProguardFiles("consumer-rules.pro")
1616
}
@@ -68,7 +68,7 @@ afterEvaluate {
6868
from(components["release"])
6969
groupId = "com.github.i2hammad"
7070
artifactId = "ad-manage-kit"
71-
version = "2.9.0"
71+
version = "3.0.0"
7272
}
7373

7474

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/AdManager.kt

Lines changed: 487 additions & 94 deletions
Large diffs are not rendered by default.

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/AppOpenManager.kt

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import android.view.LayoutInflater
1414
import android.view.View
1515
import android.view.WindowManager
1616
import android.view.animation.AnimationUtils
17+
import androidx.core.view.WindowCompat
18+
import androidx.core.view.WindowInsetsControllerCompat
1719
import androidx.lifecycle.DefaultLifecycleObserver
1820
import androidx.lifecycle.LifecycleOwner
1921
import androidx.lifecycle.ProcessLifecycleOwner
@@ -39,7 +41,7 @@ import kotlin.math.pow
3941
class AppOpenManager(private val myApplication: Application, private var adUnitId: String) :
4042
Application.ActivityLifecycleCallbacks, DefaultLifecycleObserver {
4143

42-
private var currentActivityRef: WeakReference<Activity>? = null
44+
private var currentActivity: Activity? = null
4345
@Volatile
4446
private var appOpenAd: AppOpenAd? = null
4547

@@ -162,11 +164,11 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
162164
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
163165
addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
164166
addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
165-
decorView.systemUiVisibility = (
166-
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
167-
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
168-
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
169-
)
167+
// Use modern WindowInsetsController API (replaces deprecated systemUiVisibility)
168+
WindowCompat.setDecorFitsSystemWindows(this, false)
169+
WindowCompat.getInsetsController(this, decorView).apply {
170+
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
171+
}
170172
}
171173

172174
val overlay = dialogView.findViewById<View>(R.id.overlay)
@@ -322,16 +324,18 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
322324

323325
// If ad is available, show it
324326
if (!isShowingAd.get() && isAdAvailable() && !skipNextAd.get() && !AdManager.getInstance().isDisplayingAd()) {
325-
Log.e(LOG_TAG, "Will show ad.")
327+
if (currentActivity == null) {
328+
Log.e(LOG_TAG, "Cannot show ad: currentActivity is null (WeakReference cleared)")
329+
return
330+
}
326331

332+
Log.d(LOG_TAG, "Showing ad on activity: ${currentActivity.javaClass.simpleName}")
327333
val fullScreenContentCallback = createFullScreenContentCallback("regular", null)
328334

329-
currentActivity?.let { activity ->
330-
appOpenAd?.apply {
331-
setOnPaidEventListener(createPaidEventListener())
332-
setFullScreenContentCallback(fullScreenContentCallback)
333-
show(activity)
334-
}
335+
appOpenAd?.apply {
336+
setOnPaidEventListener(createPaidEventListener())
337+
setFullScreenContentCallback(fullScreenContentCallback)
338+
show(currentActivity)
335339
}
336340
} else if (!isAdAvailable() && currentActivity != null) {
337341
// No cached ad available - always show welcome dialog while fetching
@@ -520,7 +524,7 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
520524
/**
521525
* Get current activity safely
522526
*/
523-
private fun getCurrentActivity(): Activity? = currentActivityRef?.get()
527+
private fun getCurrentActivity(): Activity? = currentActivity
524528

525529
/**
526530
* Check if activity is excluded with performance optimization
@@ -655,7 +659,7 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
655659
appOpenAd = null
656660

657661
// Clear activity reference
658-
currentActivityRef = null
662+
currentActivity = null
659663

660664
// Clear caches
661665
excludedActivityNames.clear()
@@ -715,6 +719,65 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
715719
fetchAdWithRetry(0)
716720
}
717721

722+
/**
723+
* Prefetch the next app open ad in background.
724+
* Call this when you know the user will leave and return to the app.
725+
*
726+
* Use cases:
727+
* - Before launching external intent (camera, browser, etc.)
728+
* - Before starting an activity that will return
729+
* - When user is about to leave for a known reason
730+
*
731+
* When user returns:
732+
* - If ad is ready: shows instantly (no dialog)
733+
* - If still loading: welcome dialog waits for it
734+
*
735+
* @param onPrefetchStarted Optional callback when prefetch starts (true) or skipped (false)
736+
*
737+
* Example:
738+
* ```kotlin
739+
* // Before launching camera
740+
* appOpenManager.prefetchNextAd()
741+
* startActivityForResult(cameraIntent, REQUEST_CODE)
742+
* ```
743+
*/
744+
@JvmOverloads
745+
fun prefetchNextAd(onPrefetchStarted: ((Boolean) -> Unit)? = null) {
746+
// Don't prefetch if user has purchased
747+
val purchaseProvider = BillingConfig.getPurchaseProvider()
748+
if (purchaseProvider.isPurchased()) {
749+
Log.d(LOG_TAG, "User has purchased, skipping prefetch.")
750+
onPrefetchStarted?.invoke(false)
751+
return
752+
}
753+
754+
// Don't prefetch if ad already available or currently loading
755+
if (isAdAvailable()) {
756+
Log.d(LOG_TAG, "Ad already available, skipping prefetch.")
757+
onPrefetchStarted?.invoke(false)
758+
return
759+
}
760+
761+
if (isLoading.get()) {
762+
Log.d(LOG_TAG, "Ad already loading, skipping prefetch.")
763+
onPrefetchStarted?.invoke(false)
764+
return
765+
}
766+
767+
Log.d(LOG_TAG, "Prefetching next app open ad...")
768+
AdDebugUtils.logEvent(adUnitId, "prefetch", "Prefetching next app open ad", true)
769+
onPrefetchStarted?.invoke(true)
770+
fetchAd()
771+
}
772+
773+
/**
774+
* Check if an ad is currently being loaded.
775+
* Useful to know if prefetch is in progress.
776+
*
777+
* @return true if ad is currently loading
778+
*/
779+
fun isAdLoading(): Boolean = isLoading.get()
780+
718781
/**
719782
* Fetch ad with exponential backoff retry logic.
720783
* Uses isLoading flag to prevent concurrent ad requests.
@@ -971,24 +1034,21 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
9711034
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
9721035

9731036
override fun onActivityStarted(activity: Activity) {
974-
currentActivityRef = WeakReference(activity)
1037+
// Only update when not showing ad to avoid capturing AdActivity
1038+
if (!isShowingAd.get()) {
1039+
currentActivity = activity
1040+
}
9751041
}
9761042

977-
override fun onActivityResumed(activity: Activity) {
978-
currentActivityRef = WeakReference(activity)
979-
}
1043+
override fun onActivityResumed(activity: Activity) {}
9801044

9811045
override fun onActivityStopped(activity: Activity) {}
9821046

9831047
override fun onActivityPaused(activity: Activity) {}
9841048

9851049
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
9861050

987-
override fun onActivityDestroyed(activity: Activity) {
988-
if (getCurrentActivity() == activity) {
989-
currentActivityRef = null
990-
}
991-
}
1051+
override fun onActivityDestroyed(activity: Activity) {}
9921052

9931053
/**
9941054
* Adds an activity class to the set of excluded activities.
@@ -1015,8 +1075,10 @@ class AppOpenManager(private val myApplication: Application, private var adUnitI
10151075
override fun onStart(owner: LifecycleOwner) {
10161076
val purchaseProvider = BillingConfig.getPurchaseProvider()
10171077
if (!purchaseProvider.isPurchased()) {
1018-
showAdIfAvailable()
1019-
Log.d(LOG_TAG, "onStart")
1078+
currentActivity?.let {
1079+
Log.d(LOG_TAG, "onStart - showing ad on: ${it.javaClass.simpleName}")
1080+
showAdIfAvailable()
1081+
}
10201082
}
10211083
}
10221084

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/InterstitialAdBuilder.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,13 @@ class InterstitialAdBuilder private constructor(private val activity: Activity)
485485
showWithDialog(showCallback, onComplete)
486486
}
487487
}
488+
489+
AdLoadingStrategy.FRESH_WITH_CACHE_FALLBACK -> {
490+
// Try fresh first, fall back to cache if fresh load fails/times out
491+
// AdManager.forceShowInterstitialInternal now preserves cached ad as fallback
492+
if (debugMode) android.util.Log.d("InterstitialBuilder", "FRESH_WITH_CACHE_FALLBACK: Fetching fresh, cached ad as fallback")
493+
showWithDialog(showCallback, onComplete)
494+
}
488495
}
489496
}
490497

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/NativeAdManager.kt

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,18 @@ object NativeAdManager {
207207
/**
208208
* Retrieves a cached native ad for the specified ad unit with enhanced tracking.
209209
*
210+
* **IMPORTANT: Destructive Read Pattern**
211+
* This method REMOVES the ad from cache when retrieved. This is by design:
212+
* - Each cached ad should only be displayed once
213+
* - Prevents stale ad references and memory leaks
214+
* - Ensures proper ad lifecycle management
215+
*
216+
* If you need to check cache availability without consuming, use [getCacheSize] instead.
217+
*
210218
* @param adUnitId The ad unit ID
211-
* @param enableFallbackToAnyAd If true, returns any available cached ad when specific ad unit has no cache
212-
* @return A cached native ad, or null if none available
219+
* @param enableFallbackToAnyAd If true, returns any available cached ad when specific ad unit has no cache.
220+
* Fallback priority: same base ad unit variants → cross ad unit (if enableCrossAdUnitFallback = true)
221+
* @return A cached native ad, or null if none available. The returned ad is REMOVED from cache.
213222
*/
214223
@JvmOverloads
215224
fun getCachedNativeAd(adUnitId: String, enableFallbackToAnyAd: Boolean = false): NativeAd? {
@@ -342,12 +351,78 @@ object NativeAdManager {
342351
}
343352
}
344353

345-
// No cached ads found for the same ad unit
354+
// No cached ads found for the same ad unit - try cross-ad-unit fallback if enabled
355+
if (AdManageKitConfig.enableCrossAdUnitFallback) {
356+
logDebug("Same ad unit fallback failed, trying cross-ad-unit fallback for $requestedAdUnitId")
357+
return getCrossAdUnitFallback(requestedAdUnitId)
358+
}
359+
346360
cacheMisses.incrementAndGet()
347361
logDebug("Fallback failed for $requestedAdUnitId: no cached ads available for the same ad unit")
348362
return null
349363
}
350364

365+
/**
366+
* Cross ad unit fallback - returns ANY available cached ad from ANY ad unit.
367+
* Used when enableCrossAdUnitFallback is true and no ads are found for the same base ad unit.
368+
*
369+
* @param requestedAdUnitId The originally requested ad unit ID (for logging)
370+
* @return A cached native ad from any ad unit, or null if no ads are cached
371+
*/
372+
private fun getCrossAdUnitFallback(requestedAdUnitId: String): NativeAd? {
373+
val currentTime = System.currentTimeMillis()
374+
val availableAdUnits = cachedAds.keys.toList()
375+
376+
// Sort by ad units with most cached ads (prioritize units with more ads)
377+
val sortedAdUnits = availableAdUnits.sortedByDescending { adUnitId ->
378+
cachedAds[adUnitId]?.size ?: 0
379+
}
380+
381+
for (fallbackAdUnitId in sortedAdUnits) {
382+
synchronized(getLockForAdUnit(fallbackAdUnitId)) {
383+
val adList = cachedAds[fallbackAdUnitId]
384+
if (adList != null && adList.isNotEmpty()) {
385+
// Clean up expired ads first
386+
cleanupExpiredAds(fallbackAdUnitId, adList, currentTime)
387+
388+
// Get the most recent valid ad
389+
val validAd = adList.lastOrNull()
390+
391+
if (validAd != null) {
392+
// Update access statistics
393+
validAd.lastAccessTime = currentTime
394+
validAd.accessCount++
395+
396+
// Remove from cache
397+
adList.remove(validAd)
398+
399+
// Update performance counters
400+
cacheHits.incrementAndGet()
401+
totalAdsServed.incrementAndGet()
402+
403+
val ageMs = validAd.getAgeMs(currentTime)
404+
logDebug("Cross-ad-unit fallback hit: requested $requestedAdUnitId, served from $fallbackAdUnitId (age: ${ageMs}ms)")
405+
406+
// Track analytics with cross-fallback flag
407+
trackEvent("native_ad_served", mapOf(
408+
"ad_unit_id" to requestedAdUnitId,
409+
"fallback_ad_unit_id" to fallbackAdUnitId,
410+
"age_ms" to ageMs,
411+
"access_count" to validAd.accessCount,
412+
"source" to "cache_fallback_cross_unit"
413+
))
414+
415+
return validAd.ad
416+
}
417+
}
418+
}
419+
}
420+
421+
cacheMisses.incrementAndGet()
422+
logDebug("Cross-ad-unit fallback failed for $requestedAdUnitId: no cached ads available in any ad unit")
423+
return null
424+
}
425+
351426
/**
352427
* Extracts the base ad unit ID by removing known screen suffixes.
353428
*/

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/NativeAdTemplate.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ enum class NativeAdTemplate(
112112
displayName = "Pill Banner"
113113
),
114114

115+
MEDIUM_HORIZONTAL(
116+
layoutResId = R.layout.layout_native_medium_horizontal,
117+
shimmerResId = R.layout.layout_shimmer_medium_horizontal,
118+
displayName = "Medium Horizontal"
119+
),
120+
115121
SPOTLIGHT(
116122
layoutResId = R.layout.layout_native_spotlight,
117123
shimmerResId = R.layout.layout_shimmer_spotlight,

AdManageKit/src/main/java/com/i2hammad/admanagekit/admob/NativeTemplateView.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ class NativeTemplateView @JvmOverloads constructor(
381381
NativeAdTemplate.MATERIAL3,
382382
NativeAdTemplate.MINIMAL,
383383
NativeAdTemplate.APP_STORE,
384+
NativeAdTemplate.MEDIUM_HORIZONTAL,
384385
NativeAdTemplate.VIDEO_MEDIUM,
385386
NativeAdTemplate.VIDEO_SQUARE -> NativeAdIntegrationManager.ScreenType.MEDIUM
386387

AdManageKit/src/main/java/com/i2hammad/admanagekit/config/AdLoadingStrategy.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ package com.i2hammad.admanagekit.config
2929
*
3030
* Example: User performs task -> check cache -> if available show immediately,
3131
* if not fetch new ad with timeout -> show if loaded, skip if timeout
32+
*
33+
* ### FreshWithCacheFallback
34+
* Load fresh ad first, fall back to cache if loading fails.
35+
* - Always tries to load fresh ad first
36+
* - If fresh load fails, uses cached ad as fallback
37+
* - Successfully loaded ads are cached for subsequent requests
38+
* - Best for: RecyclerView scenarios where ads are requested multiple times
39+
*
40+
* Example: RecyclerView item binds -> try load fresh ad -> if fails use cached ->
41+
* on success cache for next bind
3242
*/
3343
enum class AdLoadingStrategy {
3444
/**
@@ -47,5 +57,14 @@ enum class AdLoadingStrategy {
4757
* Check cache first, fetch if needed.
4858
* Instant show if cached, loading dialog if fetching.
4959
*/
50-
HYBRID
60+
HYBRID,
61+
62+
/**
63+
* Load fresh ad first, fall back to cache if loading fails.
64+
* Ideal for RecyclerView scenarios where:
65+
* - You want to try loading a fresh ad each time
66+
* - If fresh load fails, use cached ad as fallback
67+
* - Successfully loaded ads are cached for subsequent requests
68+
*/
69+
FRESH_WITH_CACHE_FALLBACK
5170
}

0 commit comments

Comments
 (0)