NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
pub.dev
Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Offline-verified VIP codes, GDPR/COPPA/CCPA consent, safety caps, AdEvent stream, debug overlay.
Last release 3 days ago
05 Oct 2026
Ships fairly regularly
a new release about every 2 weeks
Nearly every release is documented
notes for 57 of the last 60 stable releases
Nothing withdrawn
no release was ever pulled
6 months old
70 releases · first in 2026
One column per month.
applovin_admob_sdk 3.4.3
applovin_admob_sdk 3.4.3
AdMobAdapter App Open, interstitial, rewarded and
rewarded-interstitial load callbacks now ignore a result that lands while the
slot is showing. A late or duplicate fill used to call markReady(), moving
the slot out of showing while its ad was on screen: the fullscreen busy
check then read "not busy" (another fullscreen could stack), the on-screen
ad's reference and requestId were overwritten, and after the dismiss the slot
held a stale cached ad it could not show. A rejected fill is disposed so its
native object is not leaked; a late failure no longer drops a live show into
cooldown. A fill wrapping the very native ad already held is never disposed
(the four internal bridge wrappers now compare by the native ad they wrap,
since a new wrapper is built per callback), because disposing it would cancel
its dismiss callback. A failure or thrown error that arrives while the slot already
holds a loaded or shown ad is ignored too (an ordinary failed load still fails
the slot). Same class as the AppLovin
fix in 3.4.2. Each load request now also carries a per-request token, so a
result from a request the load watchdog already abandoned can no longer fail,
fill or answer the host callback of the NEWER request that took its place.
Trade-off: a good late fill from an abandoned request is now released instead
of being used if a newer request is still loading. No effect on AppLovin.applovin_admob_sdk 3.4.2
applovin_admob_sdk 3.4.2
AppLovinAdapter App Open onAdHidden and
onAdDisplayFailed cancelled the show watchdog BEFORE discarding a stale
(other-creativeId) callback, so a late callback from a previous cycle could
disarm the current cycle's watchdog; if that cycle's own callback was also
lost, the caller never resolved. The timer is now cancelled only after the
stale check. No effect on AdMob.AppLovinAdapter App Open, interstitial and rewarded
onAdLoaded and onAdLoadFailed callbacks now discard a load result that
lands while the slot is showing. Previously a late or duplicate load called
markReady() (or markFailed()), moving
the slot out of showing; the App Open watchdog tick then exited silently
(it requires isShowing), so a lost hidden callback was never resolved and
the caller hung. The late load also overwrote the tracked creativeId, so
the current ad's own hidden callback then looked stale and was dropped.
No effect on AdMob.Added (T146): CohortOptimizer for privacy-safe local historical provider recommendation with on-device Ed25519 signature verification.
Added (T146): CohortOptimizer for privacy-safe local historical provider recommendation
with on-device Ed25519 signature verification.
Added (T142): ScenarioRunner for offline deterministic QA and test execution
with FakeAdProviderAdapter, AdEventLog, and MonetizationDigitalTwin replay.
Added (T141): InFeedAdListView.builder widget for interleaving NativeAdWidget
into scrollable feeds with exact index mapping via InFeedIndexCalculator.
Added (T131): Mainland China setup guide for AppLovin MAX-only deployments, CSJ mediation, and GMS-free ROM requirements.
Tests: Added physical-device integration tests for T141, T142, and T146 on
TECNO KJ7 (115333744A005844).
Tests: Added 3 admob_adapter_test.dart cases covering compatibility
stubs (view IDs, route-paused flags) and early-return initialization;
admob_adapter.dart line coverage is now >77%.
Chores: Fixed the Android NDK 28.2.13676358 version mismatch in the
example app that caused Gradle warnings during integration-test builds.
Changed (T173 hardening): BannerAdWidget now checks the adapter capability
BannerErrorSelfCollapse/AdManager.collapsesBannerOnError instead of
hardcoding the AdMob provider when distinguishing internal banner error
self-collapse from real external invisibility. AdMob opts in; AppLovin and
custom adapters keep the safe default (false).
Tests: Added unit/widget coverage for the banner self-collapse capability
and expanded gma_bridge_test.dart to cover production fullscreen wrapper
show callbacks, rewarded SSV, reward callbacks, and all 4 fullscreen load
failure callbacks; gma_bridge.dart line coverage is now >90%.
Release 3.2.1: Fix R173 & T193 real-device integration under AdMob
Release 3.2.1: Fix R173 & T193 real-device integration under AdMob
BannerAdWidget._onVisibilityChanged distinguished internal
error self-collapse (AnimatedSize height = 0 on onAdFailedToLoad) from
external scroll/navigation invisibility. Keeps errored AdMob banner instances
registered in InlineAdInstanceRegistry so the Debug Overlay retains the
failed slot count and needsRecovery debt is preserved for the resume retry
scanner. External route push, TickerMode, dispose(), and manual
active: false remain immediate and continue to dispose cleanly.AdManager._selfCheckLoad now checks slot.isReady BEFORE
invoking load(). AdMobAdapter validates freshness against its private cached
native ad object reference rather than the logical slot alone; calling load()
on an already-ready slot could replace a healthy preloaded ad with a network
request leading to cooldown/no-fill. A health diagnostic must never destroy
existing ready inventory.r173_debug_overlay_banner_row_test.dart by explicitly
revoking VIP before testing banner mount, and added preload settling buffer
in t193_self_check_already_ready_test.dart to prevent secondary preload
completion races during slot state seeding. Verified 100% pass on real device
(TECNO BG6) and iOS Simulator under AdMob.Fixed (2026-09-24): AdManager.debugShouldSkipRealAppLovinNativeView was marked @visibleForTesting, but unlike every other debug* seam in this file it'
AdManager.debugShouldSkipRealAppLovinNativeView
was marked @visibleForTesting, but unlike every other debug* seam in
this file it's genuinely called from production code in a different
library (NativeAdWidget), not just tests — caught by
dart pub publish --dry-run's invalid_use_of_visible_for_testing_member
warning. Changed to @internal, which the existing test/api_golden_test.dart
tooling already treats the same as @visibleForTesting for public-API
purposes.round40_readiness_controller_demo_test.dart —
AdReadinessSplashController's own splash re-arms for another +30s if a
real App Open ad is still in flight when its hard-cap would otherwise
fire (same behavior AdManager's own splash logic has), which this
test's original 30s wait window didn't cover. Widened to 140s (measured
4/4 clean real-device runs using ~100-102s with a 100s window — right at
the edge — before settling on 140s for real margin).vip_watch_ad_to_extend_test.dart and
vip_fast_refill_demo_test.dart both navigated via the "VIP / redeem"
HomePage tile, which opens VipRedeemScreen — a different page entirely.
The buttons these tests actually need ("Watch ad → +3 days VIP (stack)"
and "End VIP now") live on VipDemoPage, reached via the "VIP API
playground" tile instead. Neither test had ever been on the right page;
not a timing issue.multi_instance_ad_test.dart — BannerDemoPage
and NativeDemoPage each embed a third, unrelated Banner/NativeAdWidget
(the "IndexedStack via buildBanner()/... (T153/T154)" examples
demonstrating the IndexedStack + active pattern), so the T65
multi-instance assertion found 3 instead of 2 — not a leaked
previous-route instance as first assumed. Now excludes that widget
(Native's T154 has a ValueKey; Banner's T153 doesn't, so it's excluded
by IndexedStack ancestry instead — both need skipOffstage: false
since the non-selected IndexedStack branch is offstage).t136_waterfall_tuner_persistence_test.dart was
never actually reachable — its single emitRevenue() call per session
could never satisfy recommendation()'s per-provider
otherRevenueSamples >= minSampleSize gate (a round-61 audit fix,
gated separately from the summed-across-both-providers load-attempt
check the test's own comment described), so it returned null
regardless of whether persistence worked. Found by first ruling out
timing (confirmed via a raw-SharedPreferences dump that session A's
write landed correctly) and a missing await on WaterfallTuner's
documented ready future (real fixes, kept, but not the actual cause).
Also excluded round37_reload_while_showing_test.dart from
integration-retry.sh's full-suite run — its own header says a HUMAN
must manually tap a real interstitial's close button, same requirement
as app_open/interstitial/rewarded_ad_test.dart, and actually landed
the round37_coppa_hardstop_test.dart exclusion a prior CHANGELOG entry
had already claimed but the code edit was missed.google_mobile_ads from ^7.0.0 to
'>=9.0.0 <9.1.0', and this package's own environment floor from Flutter
>=3.27.0/Dart >=3.6.0 to Flutter >=3.38.1/Dart >=3.10.0. This
resolves the CocoaPods pinning wall documented in CLAUDE.md — a
consuming app can now pair applovin_max ^4.6.4 with
gma_mediation_applovin 2.6.2 (or 2.6.3) directly, no
dependency_overrides hold-back needed. google_mobile_ads is pinned
below 9.1.0 on purpose: that version ships a confirmed upstream iOS
build regression (non-modular-header Xcode failure from its new Ad
Preloading API) — verified locally (flutter build ios --simulator
fails on 9.1.0, succeeds on 9.0.0). Consuming apps on Flutter
<3.38.1 must upgrade Flutter before taking this release. On Android,
google_mobile_ads 9.0.0's native play-services-ads 25.3.0 ships
Kotlin metadata compiled with Kotlin 2.3.0 — a consuming app's own
Kotlin Gradle plugin must be >= 2.3.0 (was 2.1.0 in this SDK's
example app) or compileDebugKotlin fails with "Module was compiled
with an incompatible version of Kotlin"; Kotlin 2.3.0 also removed the
old android.kotlinOptions { jvmTarget = ... } DSL in favor of a
top-level kotlin { compilerOptions { ... } } block.multi_instance_ad_test.dart — BannerDemoPage
and NativeDemoPage each embed a third, unrelated Banner/NativeAdWidget
(the "IndexedStack via buildBanner()/... (T153/T154)" examples
demonstrating the IndexedStack + active pattern), so the T65
multi-instance assertion found 3 instead of 2 — not a leaked
previous-route instance as first assumed. Now excludes that widget
(Native's T154 has a ValueKey; Banner's T153 doesn't, so it's excluded
by IndexedStack ancestry instead — both need skipOffstage: false
since the non-selected IndexedStack branch is offstage).NativeAdWidget._onNativeErrorChanged, round-38 audit fix) could crash
when an on-device integration test used a fake debugAdapterFactory
under an AppLovin config — the fake adapter never runs real native
AppLovinSdk init, so the retry's real MaxNativeAdView platform view
NPEs deep inside AppLovin's own native SDK. Added
AdManager.debugForceSkipRealAppLovinNativeView (explicit opt-in,
guarded like every other debug* seam — always off in release) so a
test can request the real platform view be skipped without inferring it
from the adapter's type, which would have also affected
test/native_ad_widget_test.dart's unrelated widget tests. Not reachable
in production (debugAdapterFactory itself is release-blocked)..github/scripts/integration-retry.sh's
AppLovin-only-test exclusion regex didn't match
r36_real_applovin_appopen_over_banner_test.dart (a different naming
shape than the *_ad_test.dart files it already excluded), so a full
local run forcing AD_PROVIDER_ADMOB=true would run it by mistake and
fail on its own self-guard assertion. Also excluded
round37_coppa_hardstop_test.dart, same class of AppLovin-only test.example/integration_test/ files had
fixed, too-short polling windows (originally sized for CI's emulator)
for real-device navigation/tile-render timing
(r173_debug_overlay_banner_row_test.dart,
gaid_reset_on_destroy_integration_test.dart,
revenue_dashboard_test.dart, rewarded_interstitial_ad_test.dart) —
widened them. gaid_reset_on_destroy_integration_test.dart also had two
real bugs found via a real-device diagnostic dump of on-screen text: an
ambiguous two-widget tap() (a popped route's AppBar title and
HomePage's tile briefly share the same exact text mid-transition, same
root cause already documented in revenue_dashboard_test.dart — fixed
by targeting the tile through its DemoTile ancestor instead of a bare
.first), and — the real root cause of the residual ~10-20% flake —
changing tester.view.physicalSize immediately before a tap() with
only one bare pump() in between: getCenter() can compute the tap
coordinate against the stale pre-resize layout on a real device, silently
landing on empty space instead of throwing. Fixed with a few
duration-pumps to let the relayout settle first. Verified with 20
consecutive clean real-device runs after the fix (was intermittently
stuck on HomePage before, confirmed via the diagnostic, not a network
race as initially suspected).diagnostics_demo_test.dart — a single
zero-duration pump() right after tapping "Run runIntegrationSelfCheck()"
sometimes missed the frame where the button's spinner
(CircularProgressIndicator) is shown, on a real device. Poll a few short
pumps instead, falling through immediately if the check already finished
(a legitimately fast real per-slot result shouldn't fail the test either).
Found via a full 127-file real-device run (Pixel 7 Pro, then Tecno KJ7);
bisected against the pre-migration commit first to confirm these
predated the google_mobile_ads bump above, not caused by it.verifySignedVipKey and
VipManager.redeemSignedKey now reject the legacy AVP1 key format
(no expiry, no app binding) by default. Pass allowLegacyV1: true if
your app has already distributed real AVP1 codes and needs them to keep
redeeming — tool/vip_mint.dart has minted AVP2 by default since
2.0.0, so this only affects codes minted with --v1.lib/ from
scratch — the annotation alone doesn't block a release build, so every
one of these needed a runtime kReleaseMode/_testSeamsBlocked/
debugSimulateReleaseModeForTestSeams guard at its actual read/call
site) found 18 more @visibleForTesting debug seams left ungated after
round 69's original sweep — same class of gap as rounds 68-71, just
missed the first four times because each seam has a different name, in
a different file:
AdManager: debugFirstInstallGuardFactory, debugForceAutoUmpError,
debugInitRetryDelays, debugConsentGateRecoveryRetryDelay,
debugBumpInitGen, debugReconnectDebounce,
debugResumeConsentRecheckTimeout, debugResetPreInitExperimentId,
debugReconcileProviderExplorationSlot, debugResetLastSkipConsentManager: debugPersistDelay, debugApplyBarrierAdEventLog: debugPersistDelay (a same-named-but-different-class
sibling of ConsentManager's, missed by grep for exactly that reason),
debugInjectRawEntryIabStorage.debugOpenOverride — could have hijacked every
TCF/GPP/US-Privacy readAdPreferences.debugFillRateWriteDelay, AdPreferences.resetForTestVipManager.resetSaveQueueForTest, RedeemedKeyLedger.resetWriteChainForTest
— both sit directly in the VIP anti-replay/anti-resurrection write
serialization this SDK's whole VIP security model depends onUmpConsentManager.debugUmpFormBackstopOverride — could have let ads
show over a live UMP consent formAttConsentManager.resetPendingAttRequestSafeLogger.resetForTestbootstrap()'s debugRequestAtt/debugRequestUmp parameters — could
have skipped the real ATT/UMP prompts entirely in a release buildRevenuePanel.debugModeOverride — could have shown a real live-revenue
number to the end user (this one guarded with kReleaseMode directly,
not a simulate-flag, same as bootstrap()'s two above — a compile-time
constant needs no runtime flag to already be unconditionally safe)Added: wake lock — AdConfig.keepScreenOnDuringSession (default true) keeps the device screen on for the whole SDK session, so a rewarded video or an i
AdConfig.keepScreenOnDuringSession (default
true) keeps the device screen on for the whole SDK session, so a
rewarded video or an idle splash waiting on an App Open ad isn't
interrupted by the device auto-locking. Enabled on a successful
AdManager.initialize(), always released on AdManager.destroy().
AdManager.setKeepScreenOn(bool) overrides it at runtime independent of
init state, for a host that wants to flip it mid-session. Backed by
wakelock_plus, pinned to exactly 1.4.0 (not ^1.4.0) — the newest
version still satisfying this package's own Dart/Flutter floor; 1.5.0+
needs Dart >=3.10.0, same class of wall as google_mobile_ads 8/9 (see
this file's own pinning-wall notes and CLAUDE.md).AdManager.autoRequestUmpConsent's
UMP flow ran fire-and-forget, so adapter.initialize() (the real
AppLovin/AdMob native SDK) started immediately after, while EEA/UK
consent was still resolving. canRequestAds being closed first meant no
ad request went out before consent, but the native SDK's own init-time
behavior was never gated on it. Native init now waits for the UMP flow
to actually resolve (bounded by its existing 240s hard cap, so this
cannot hang forever) before proceeding.@visibleForTesting is a lint, not a runtime
check) also existed on 4 seams added since: most severe,
AdManager.debugApplyConfigVipGaidWhitelist could self-grant a 50-year
VIP entry with no signature check in a release build. Also fixed:
VipManager.clearRedeemedKeyLedgerForTest (could wipe the anti-replay
ledger for signed VIP keys), debugFormDismissTimeoutOverride in the
UMP consent flow, and 3 static consent barriers in AdManager.Fixed (round 70 audit, MAJOR): the debug-seam-guard gap rounds 68/69 fixed on AdManager (@visibleForTesting is a lint, not a runtime check) also exist
AdManager (@visibleForTesting is a lint, not a runtime
check) also existed on AppLovinAdapter, AdMobAdapter,
AdSafetyConfig and IabStorage — 11 seams across the 4 files. Most
severe: debugSimulateRewardedShowAndDismiss on both ad adapters fires
the reward-granting callback directly with no real ad shown, reachable
in a shipped app via AdManager().adapter as AdMobAdapter;
AdSafetyConfig.debugExpireSuspiciousPause defeats the invalid-traffic
throttle outright. All 11 now share the same kReleaseMode-gated no-op
guard as AdManager's. See doc/audit/audit_round70_consolidated.md.Fixed (round 69 audit, MAJOR): round 68 guarded 5 AdManager @visibleForTesting seams (debugSetAdapter/debugAdapterFactory/ debugVipManager/debugConsen
AdManager
@visibleForTesting seams (debugSetAdapter/debugAdapterFactory/
debugVipManager/debugConsentManager/debugConfig) against being
called from a shipped release build. A full adversarial pass of the rest
of ad_manager.dart (9195 lines — never fully audited before this round)
found 28 more seams sharing the identical gap, including
debugApplyUmpConsentResult (forges GDPR consent state directly),
debugResetGuardState (wipes every footgun guard at once), and
debugResetBannerCooldown/debugResetMrecCooldown/
debugResetNativeCooldown (clear the ad-request-spam cooldowns the
safety layer depends on). All 33 now share the same kReleaseMode-gated
no-op guard. See doc/audit/audit_round69_consolidated.md for the full
list and the (verified-safe) members deliberately left unguarded.Bumped: connection_notifier ^4.1.0 → ^4.1.1 (patch only) — the one direct dependency that was behind latest with no version-floor tradeoff attached. N
connection_notifier ^4.1.0 → ^4.1.1 (patch only) — the
one direct dependency that was behind latest with no version-floor
tradeoff attached. No behavior change expected.kQaTestDeviceHashes in lib/src/config/ad_config.dart, now 17 total).
A live incident on 2026-09-10/09-20 established that an AdMob test-device
hash depends on the APK's signing certificate, not just device
identity — a debug build and a release-signed build on the same physical
phone report different hashes, and the hash can change again between
release-signing keys. Two devices (TECNO BG6, TECNO KJ7) needed their
debug/release-variant hashes added alongside the ones already present;
none were removed, matching the "only add, never remove" policy a stale
hash is harmless under.Docs-only release, no code changes.
Docs-only release, no code changes.
example/ wholesale into a real
app" warning only lived deep in README's Pitfalls checklist — exactly
where someone who opens pub.dev's Example tab and copies the whole
main.dart file first, before reading anything else, would never see
it. That's the single most predictable way to conclude "the SDK doesn't
work": the copied file replaces the host app's own UI with a 48-page
demo menu, keeps Google's placeholder test ad-unit IDs, and skips the
native Android/iOS setup (AppLovin SDK key, AdMob App ID) that no
example file can supply for you. Added a bold warning as the literal
first lines of example/lib/main.dart, pointing to the README Quick
start instead.Docs-only release, no code changes. Reverts part of 3.0.5.
Docs-only release, no code changes. Reverts part of 3.0.5.
example/example.md so pub.dev's Example tab
would show a short walkthrough instead of example/lib/main.dart's raw
source. On review this traded away something deliberate: with
example.md present, a visitor never sees a single line of real,
working code on the package page — only prose describing it — which is
exactly the outcome main.dart's own top-of-file comment says was
already considered and rejected (splitting the example up "makes the
package look unfinished on pub.dev"). Removed example/example.md; the
Example tab shows main.dart again.example/lib/main.dart had ~124 lines of internal
task-tracking shorthand in comments (T117, T94, Round-27 audit fix, R2-02, ...) meaningless to anyone outside this repo, right in
the one file pub.dev shows to every visitor evaluating the package.
Stripped the shorthand from comments, kept every explanation, changed
no code — same 4949 lines, same 48 demo pages, flutter analyze clean.
(A handful of matching UI-visible string literals, e.g. a demo page
title like 'Banner adaptive sizing (T157)', were left alone — those
are code values, not comments, and touching them risks breaking a
widget key or golden test elsewhere.)Docs-only release, no code changes.
Docs-only release, no code changes.
README.md was 142,860 bytes — over pub.dev's ~131,072 byte
(128 KB) render limit — so the live pub.dev page silently cut off content
mid-sentence past roughly the "Other advanced opt-in modules" section,
hiding "Consent & compliance", "Public API", "FAQ", "Migration",
"Support" and "License" from every visitor. Condensed verbose prose
(mainly in "VIP system" and "What's new in 2.0.0") down to 128,203 bytes
— under the limit — without touching "Quick start", "Consent &
compliance", "Known limitations", or any code sample.initialize()
requests it automatically via AdConfig.autoRequestUmpConsent (default
true) — the sample just didn't say so. Added a comment explaining it.example/lib/main.dart's
raw ~5,000-line source (its own internal round/task-number comments and
all) instead of a readable walkthrough, because pub.dev's file-priority
order for that tab (example/example.md > example/lib/main.dart > ...
example/README.md) put the actual entry-point file ahead of the README —example/README.mdwas never going to be shown whilemain.dartexists, regardless of its content. Addedexample/example.md(highest priority in that order) with the walkthrough + demo-page table, and added the previously-missing iOS ATT call to both example docs' quickstart snippet.
doc/AD_PROMPT_FLUTTER.MD had ~25 scattered Q10/Q14A/Q27
-style references to a numbered question list that isn't included
anywhere in this repo and no longer exists — removed them; they added no
resolvable information for either a human reader or an AI agent.Fixed (round 68 audit, MAJOR, `agy`/Gemini): tool/vip_keygen.dart writes the Ed25519 VIP-signing private key to .vip-private-key (or a custom --privat
agy/Gemini): tool/vip_keygen.dart
writes the Ed25519 VIP-signing private key to .vip-private-key (or a
custom --private-out path) with no .gitignore entry anywhere in the
repo covering it — a git add . after generating a key would have
committed it, letting anyone with repo access mint unlimited valid VIP
codes offline. Added .vip-private-key/*.vip-private-key to both the
root and packages/ad_sdk/.gitignore. No key was ever committed.claude reviewer):
AdManager.debugSetAdapter/debugAdapterFactory/debugVipManager/
debugConsentManager/debugConfig were only @visibleForTesting — an
analyzer lint, not a runtime guard (kReleaseMode never gated them,
unlike this file's other test-seam footguns). Any code running in the
same isolate as a shipped release build — including a compromised
transitive dependency — could call one of these to silently swap out
the real ad adapter, VIP state or consent state, zeroing ad revenue or
faking VIP/consent-active with no crash and no signal. Now a no-op
(logged) once kReleaseMode is true, following the same
isActuallyRelease-style pattern already used for the consent/test-ad-ID
footgun guards. New AdManager.debugSimulateReleaseModeForTestSeams
test seam and regression tests in
test/ad_manager_debug_seam_release_guard_test.dart.IabStorage's class-level doc comment
in lib/src/core/iab_storage.dart had no code between it and the
private _GppBitReader class declared right above IabStorage —
adjacent /// blocks with nothing in between attach to whichever
declaration follows immediately, so the doc merged onto the private
class instead and never reached the generated docs for the public
IabStorage API.Fixed (round 67 audit, MAJOR): ConsentManager._load() read disk immediately on every bootstrap() call, including a reinit-without- destroy() on the sa
ConsentManager._load() read disk
immediately on every bootstrap() call, including a reinit-without-
destroy() on the same singleton (this file's own documented design).
It did not wait for an in-flight set()/reset() write on that same
instance, and a real platform-channel persist has a real async gap —
reading disk inside that gap returned the pre-write value and silently
overwrote _current/_settingsListenable with it. A host's own
set(hasUserConsent: true) landed on disk correctly, but the in-memory
current and the value actually applied to AppLovin/AdMob both
reverted to the stale prior value for the rest of the running session
— a compliance-relevant regression, not just a display glitch. Fixed
by having _load() await the existing _persistLock (round 39) before
reading disk, so a read always happens after any in-flight write lands.
New regression test in test/consent_manager_reload_race_test.dart.NativeAdWidget's
onAdLoadedCallback/onAdLoadFailedCallback never got round 46's
(R46-03) capturedAdapter identity guard — only
onAdClickedCallback/onAdRevenuePaidCallback had it. That guard
exists because AdManager.destroy() + re-initialize() swaps in a
brand-new AppLovinAdapter instance whose native registry has never
heard of an already-built widget's instanceKey; a late platform-view
callback still closing over the OLD adapter falls through to
AdManager().adapter (the NEW one) and silently plants a fresh, live
registry entry keyed by an instance that no longer belongs to it. The
old doc comment on these two callbacks assumed a same-adapter
already-disposed-key throw would catch this, which only holds for a
same-adapter dispose, not a cross-adapter swap. Fixed by adding the
same identical(adapter, capturedAdapter) guard used by the other two
callbacks. 2 new regression tests in test/native_ad_widget_test.dart.ConsentManager.bootstrap() only
ever reassigned its journal field when a caller's provenanceJournal
argument was non-null, so a host that disabled
AdConfig.enableConsentProvenanceJournal on a later initialize()
(a reinit without destroy() — this singleton survives that, by its
own documented design) kept silently appending consent-change entries
to the OLD journal, even though AdManager().consentProvenanceJournal
correctly reported null and the host had no public API left to read
or clear what kept being written. Fixed by making the reassignment
unconditional, including null — symmetric with every other value.
Also corrected a stale doc comment above the call site in
ad_manager.dart that claimed only the first bootstrap() call honors
this parameter, which directly contradicted ConsentManager.bootstrap's
own (correct) doc comment. 2 new regression tests in
test/consent_manager_provenance_test.dart.AdManager.applySignedFeatureFlags()'s
rollback guard (SignedFeatureFlags.verify(previousRevision: ...))
tracked the last-applied revision in an in-memory-only field, which
reset to null on every app restart. A stale-but-still-validly-signed,
still-unexpired feature-flags payload could therefore replay after any
cold start and re-disable a feature the app had already moved past in a
previous session (this mechanism is disable-only — arbitrator/
waterfallTuner/journeyPrefetcher/selfHealingObserver — so the
impact is an availability/degradation risk, not an entitlement bypass).
remote_ad_safety_provider's equivalent revision guard already persists
correctly via AdPreferences.getRemoteSafetyRevision()/
setRemoteSafetyRevision(); feature flags now follow the same pattern
(getFeatureFlagsRevision()/setFeatureFlagsRevision()). New
AdManager.applySignedFeatureFlags regression tests in
test/feature_flags_test.dart simulate a restart via a new
debugFeatureFlagsRevision test seam.redactSensitiveData()'s 3 patterns
only matched handwritten key: value/key=value shapes — a
JSON-quoted key ("gaid": "abc-123-def") has a " immediately after
the key name instead of whitespace/:/=, so the whole match failed
to start and the value passed through unredacted. This is a dormant gap
(no current call site logs a JSON-quoted-key string through this
defense-in-depth redactor), fixed before anything relies on it. New
regression test in test/safe_logger_test.dart.WaterfallTuner.recommendation()
gated on trailing load attempts (minSampleSize, 6) before trusting
a comparison, but the score that actually decides a recommendation
(fillRate * avgEcpmMicros) depends on _revenueMicros, a separate,
independently-sized list — a load succeeding doesn't mean that
impression ever showed and paid out. 6 load attempts could coexist with
a single revenue sample: a real reproduction (6 loads + 6 revenue
events averaging $0.10 for the current provider vs. 6 loads but only 1
revenue event at $0.50 for the other) produced a switch recommendation
driven entirely by one outlier. Fixed by additionally requiring the
recommended provider's revenue-sample count to clear the same bar
(deliberately not the current provider's — a current provider that's
genuinely failing, e.g. 0% fill rate, has 0 revenue samples too, and
that's a confident signal from a well-sampled fill rate, not a case
this gate should block). 3 new regression tests in
test/waterfall_tuner_test.dart; 4 pre-existing tests whose fixtures
emitted too few revenue events to exercise the code path they were
actually testing were updated to emit enough. 2197/2197 suite green,
flutter analyze clean.MonetizationDigitalTwin._groupByDay()
counted revenue from every AdRevenueEvent — including banner/mrec/
native — into a day's revenueMicros, but only counted fullscreen
AdShowEvents into shown (banner/mrec/native never emit one).
forecastDailyCap() divides revenueMicros / shown to get
avgRevenuePerShow, so any app running both banner and fullscreen ads
(this SDK's normal dual-format case) got a severely inflated forecast —
e.g. $50/day banner + 5 real $2 interstitials produced an
avgRevenuePerShow of $12 instead of $2, a ~6x overstatement that could
lead a host to raise its fullscreen daily cap on a false revenue signal.
Fixed by scoping the revenue sum to the same fullscreen slot types
AdShowEvent covers (appOpen/interstitial/rewarded/
rewardedInterstitial). 2 new regression tests in
test/digital_twin_test.dart cover both the exclusion and that all 4
fullscreen formats (not just interstitial) still count.AdEventLog._eventExtra() never
persisted AdRevenueEvent.requestId (or AdShowEvent.requestId) into
the compliance log, even though both classes have carried the field
since T185. Because RevenueAnomalyDetector.analyze() reads
requestId from that same persisted log to power two of its five
anomaly kinds, RevenueAnomalyKind.duplicateImpression and
.requestIdCollision could structurally never fire on real,
production-sourced data — silently, with no error or log line
indicating the feature was inert. The existing unit test for this
detector hand-built its fixture maps with 'requestId' set directly,
so it kept passing throughout and never exercised the real
AdEventLog.recordEvent() serialization path that was actually
dropping the field. Fixed by adding requestId to both event types'
serialized fields; added a pipeline-level regression test that goes
through the real AdEventLog → RevenueAnomalyDetector path instead
of a hand-built map.AdConfig.onConsentProvenanceEntryAppended — opt-in hook,
ignored unless enableConsentProvenanceJournal is true. Called right
after each ConsentProvenanceEntry is persisted to the local, on-device
hash-chain journal. verifyChain()'s own doc comment already documented
that a local-only hash chain cannot detect a fully forged chain — an
attacker with full control of the device's own storage can rewrite it
self-consistently from scratch. This hook lets a host app mirror each
entry to its own server as it happens, giving it an external anchor
outside device storage for entries recorded before any later on-device
tampering. The SDK makes no network call itself here — the callback may
be sync or async (e.g. to await an HTTP call), is never awaited by
the SDK either way (fire-and-forget, never delays a real consent
change), and any error it raises, sync or async, is swallowed.try/catch swallowing callback errors only caught a synchronous
throw. An async callback — the natural way to write "await an HTTP
call", i.e. exactly what this hook exists for — never throws
synchronously; it returns a Future that rejects instead, which the
SDK wasn't awaiting or attaching an error handler to, so the rejection
escaped as an unhandled zone error (fatal in many Crashlytics/
Sentry setups) despite the doc comment's explicit promise that a
throwing callback "never fails the underlying consent change." Fixed
by widening the field to FutureOr<void> Function(...) and attaching a
no-op catchError to the returned Future without awaiting it (stays
fire-and-forget). New regression test covers the async-throw case the
original 3 tests missed.Fixed (round 49 audit, MAJOR): AdManager().clearSdkData(scope: SdkDataErasureScope.allIncludingEntitlements) — the SDK's own documented "erase my data
AdManager().clearSdkData(scope: SdkDataErasureScope.allIncludingEntitlements) — the SDK's own documented
"erase my data" flow (Apple 5.1.1(v)/GDPR/CCPA) — also erased the iOS
Keychain flag FirstInstallGuard uses to stop the first-install 24h VIP
trial from being re-granted without a real uninstall. A user tapping that
button and reopening the app got a fresh trial every time, no reinstall
needed. The Keychain flag is no longer touched by this scope, matching
how RedeemedKeyLedger/ConsentProvenanceJournal already carve
themselves out of entitlement erasure for the same anti-abuse reasoning.AdScreenState's three full-screen ad
completion callbacks (showInterstitialAd's onDoneFlow,
showRewardedAd's onEarnedReward, showRewardedInterstitialAd's
onDone) checked mounted/disposed state before starting the ad, but
not when the ad actually finished — if the host navigated away while the
ad was still on screen, the late native "ad closed" callback ran the
host's onDone against an already-disposed screen (setState after
dispose / deactivated-widget context lookup crash). All three now guard
the completion callback too.AdSafetyConfig.resetSessionCounters()
documented itself as preserving fraud history, but was zeroing
_fullscreenImpressions/_fullscreenClicks — exactly the state its own
CTR-anomaly gate needs 5 cumulative impressions to ever evaluate. Calling
this reset repeatedly (e.g. wired to a common action, as the M2/round-6
incident already warned against for a different counter) could
permanently prevent the click-fraud gate from ever tripping, no matter
how bad the real click-through rate was.ProviderFailoverAdvisor and
FillRateMonitor counted a load failure toward their thresholds even
when the device was offline at the time, so a network flap during a
reconnect-debounce refill could run several loads that fail purely from
lost connectivity and trip a false "switch providers" recommendation or
fill-rate alert. Both now ignore a load failure while
AdManager().isConnected is false.dart run tool/vip_mint.dart/vip_keygen.dart/
vip_crl_mint.dart on a Dart 3.10+ toolchain prints
Running build hooks... to stdout ahead of the script's own output,
silently corrupting a captured $(dart run tool/vip_mint.dart ...) key
or CRL string. Docs and the CLI security test now use bare
dart tool/vip_mint.dart (no run subcommand), which doesn't trigger
the build-hooks step. (Round 49 audit, found independently by an
external agy pass.)ProviderFailoverAdvisor's open-circuit
state (_openedAt) only ever lived in memory — a restart right after the
circuit tripped (consecutive failures reaching the threshold) rehydrated
the failure count but not the open state, so shouldFailoverNextSession
silently read false again until one more real failure landed. The open
timestamp is now persisted and restored alongside the failure count.ConsentProvenanceJournal.verifyChain()
only ever re-derived its hash chain from its own currently-stored entries,
so truncating the tail or forging an entirely new chain both still
verified as valid — despite the method's own doc comment claiming it
detects an entry "removed after being recorded". Added
signConsentProvenanceJournal() (same on-device Ed25519 signed-export
this package's other compliance records already have via
signBypassAuditTrail/signComplianceReport) so an exported journal can
at least be checked for post-export tampering, and corrected
verifyChain()'s doc comment to state its real, narrower guarantee.FillRateBaselineMonitor counted a load
failure into its session tally and 7-day persisted history even when the
device was offline at the time — the same class of false signal
ProviderFailoverAdvisor/FillRateMonitor were already fixed for in
round 49, just not applied here too.FillRateBaselineMonitor's revenue
regression check gated only on load-attempt sample size
(session.attempts/baseline.attempts >= minSamples), not on how many
actual paid events fed the averages being compared — a single paid event
on each side could swing the average by ~100% and fire a regression alert
off pure n=1 noise. Now also requires revenueCount >= minSamples on
both sides before considering a revenue regression.BypassAuditTrail.clear() was
missing the same .catchError this file's flush()/_schedulePersist()
already carry (T155) — a transient persist failure inside clear() threw
uncaught out to its caller instead of being logged and absorbed.requestAttIfNeeded() held the
fullscreen-ad mutex indefinitely (until the 15-minute backstop) if the ATT
plugin call threw synchronously instead of failing asynchronously — the
same bug class requestPrivacyOptionsFlow()'s UMP path was already fixed
for (round-8 QC), just not applied to the ATT path too.installAdCrashGuard() treated
FlutterError.onError/PlatformDispatcher.onError as one all-or-nothing
unit — if a host replaced only one of the two since the last install, the
other (still this guard's own untouched wrapper) got silently re-captured
as "the previous handler" and wrapped again, permanently losing the real
original underneath it. A later destroy() then restored the guard's own
stale wrapper instead of the host's true original handler. Each handler
is now checked and (re)installed independently.AdBootstrapResult.toString() printed
the raw device GAID — a public return value a host may log or pass to a
crash reporter directly, outside this SDK's own log redaction. Now prints
hasGaid: bool, matching AttResult.toString()'s existing convention for
the IDFA.AdManager().clearSdkData() erased
ProviderFailoverAdvisor's persisted streak/circuit keys but never reset
a live advisor instance's in-memory copy — its next ad-load event would
silently re-persist the pre-erasure state right back. Added
ProviderFailoverAdvisor.resetInMemoryState(), now called from
clearSdkData() when a live advisor is enabled.NativeDemoPage._simulateWatchdogTimeout() was missing the mounted
guard after its await, unlike every other async handler in that class —
navigating away mid-call could call setState() on a disposed widget.tool/vip_keygen.dart wrote
the private-key file with default permissions, then chmod 600'd it
afterward — a real (if narrow, local-attacker-only) TOCTOU window where
the file was briefly readable by anyone else on the same machine. Now
shells out to a subprocess with umask 077 set before the file is ever
created, so no such window exists.CoreSegment.GpcSegment — every reference CMP implementation defaults
to including the optional GPC sub-segment). IabStorage's bit-packing
decoder didn't strip the segment separator before decoding, so .
(not in the base64url alphabet) threw a FormatException that every
caller's catch silently treated as "no usable signal" — a real
US-privacy opt-out expressed only in a two-segment section (the common
case, not an edge case) was invisible, closing a compliance gap that had
been an open, unresolved question since round 43. Fixed once in the
shared _GppBitReader constructor (section.split('.').first), so all
21 US-state/national/California parsers get it uniformly.Fixed (round 48 audit, MINOR): a rapid online→offline flap while a reconnect-debounce timer was pending left that timer running; since its callback on
Fixed (round 48 audit, MINOR): a rapid online→offline flap while a
reconnect-debounce timer was pending left that timer running; since its
callback only checked isInitialised/VIP status, not current
connectivity, it fired the full "network back online" refill (UMP retry,
ad refill, banner/MREC preload) while the device was actually offline
again. Wasted work, not harmful (every call already fails safely
offline), but now cancelled correctly on the offline transition.
Fixed (round 46 audit, MAJOR): round 44's fix routing pre-init
setDoNotSell() through setConsent() (so it wasn't silently dropped)
had a side effect nobody intended — it also satisfied
consentFootgunWarning's "a consent flow ran" check, even though
setDoNotSell only supplies the CCPA axis, not a GDPR/UK consent
decision. A release build with autoRequestUmpConsent: false and no
AppLovin CMP could call setDoNotSell(true) pre-init and silently
bypass the guard meant to catch exactly that configuration.
setConsent() now takes a qualifiesAsConsentFlow parameter
(default true for every genuine external caller); the internal
setDoNotSell routing passes false. (R46-01)
Fixed (round 46 audit, MINOR — a regression in round 45's own R45-04
fix): the release-blocking test-ad-ID guard and the consent-flow
guard shared one mutable flag, so setConsent() resolving normally
(as it does moments after init in any real app) silently cleared the
test-ID block too. Now its own dedicated flag, untouched by
setConsent(). (R46-02)
Fixed (round 46 audit, MINOR): BannerAdWidget/MrecAdWidget
click callbacks lacked the stale-view check their revenue callbacks
already had (round 33), so a click for an adViewId the widget had
since moved on from was still recorded. Also, NativeAdWidget's
round-45 disposed-instance guard was per-adapter-instance, so a late
callback surviving a destroy() + re-initialize() could land on,
and contaminate, the new SDK session; native click/revenue callbacks
now also require the callback's adapter to still be identical to
the one current when the listener was built. (R46-03)
Fixed (round 45 audit, MAJOR): round 44's TCF-vendor-consent fix
(applyConsentToProviders skipping AppLovinMAX.setHasUserConsent when a
real IAB TC string already exists) only guarded the post-init code path,
which never actually runs on an ordinary cold start — setConsent()
buffers instead of applying until the SDK is already initialised. The
pre-init call in AppLovinAdapter.initialize() (the one that actually
reaches AppLovin first on every launch) still called
setHasUserConsent unconditionally, silently overriding a returning EEA
user's real vendor-specific TCF consent with a coarse, AdMob-shaped
boolean. Now guarded the same way. See
doc/audit/audit_round45_consolidated.md (R45-01).
Fixed (round 45 audit, MINOR): AppLovin native ad clicks delivered
after the widget (and its native instance) was already disposed were
still recorded against the global click/invalid-traffic counters and
emitted through eventSink — unlike the revenue callback, which already
guarded against this. Now checks isNativeInstanceDisposed the same way.
(R45-02)
Fixed (round 45 audit, MINOR): loadRewardedInterstitialAd/
showRewardedInterstitialAd silently never became ready on AppLovin (MAX
has no equivalent ad unit type), indistinguishable from "not filled yet."
Now emits an explicit AdSkipEvent(reason: 'unsupported_provider') so a
host can tell the two cases apart and disable the placement
deterministically. (R45-03)
Fixed (round 45 audit, MINOR): shipping Google's public TEST AdMob ad
unit IDs in a real release build only logged a warning and
assert(false, ...) — an assertion stripped out of release builds
entirely, so the one build that needed blocking got no enforcement. Now
release-blocking like every other footgun guard: canRequestAds stays
false for the process if a release build is still on a Google test ad
unit ID. (R45-04)
Fixed (round 44 audit, MAJOR): applyConsentToProviders used to call AppLovinMAX.setHasUserConsent(bool) unconditionally with a purpose-only boolean co
applyConsentToProviders used to call
AppLovinMAX.setHasUserConsent(bool) unconditionally with a purpose-only
boolean computed for AdMob's npa flag — a value with no vendor-consent
basis for AppLovin. Per AppLovin's own MAX integration docs, the SDK
auto-reads a real IAB TCF string from platform storage the moment a
certified CMP (UMP) writes one, and the explicit setHasUserConsent call
is documented as the path for apps with no CMP at all. The explicit call
is now skipped whenever a real TC string already exists on the device,
letting MAX evaluate its own vendor consent instead of being overridden.
setDoNotSell (CCPA, an unrelated axis) is unaffected — still always
called.setDoNotSell(true) (CCPA/CPRA "Do Not
Sell" opt-out) called before initialize() was silently discarded —
logged "ignored" and returned — contradicting its own docstring's "safe
to call before initialize()" claim. Now routes through the same pre-init
buffer setConsent() already uses, so the choice survives and reaches
both providers once initialize() runs. doNotSell's getter now falls
back to the buffered value while there is no ConsentManager yet.native()'s listenables now inherit and
release the fullscreen hold the same way banner()/mrec() already do,
on both providers; a new AdManager.nativeVisible(key) drives
NativeAdWidget the same way bannerVisible already drives
BannerAdWidget. See doc/audit/audit_round44_consolidated.md finding 4.showConsentDialog, ConsentDialogStrings,
ConsentManager.showDialog/showDialogIfNeeded/updateStrings/strings,
AdConfig.autoShowConsentDialog/consentDialogStrings/
consentBarrierDismissible/consentDialogPostSplashDelay). It was a plain
Allow/Reject sheet, not a Google-certified CMP — it produced no valid TCF
consent string, so a "yes" it collected was not a valid legal basis for
personalized ads in the EEA/UK/Switzerland, yet was written straight
through to AppLovin's setHasUserConsent. Migration: use Google UMP
(autoRequestUmpConsent: true, the default) or another certified CMP
instead — see README's "Consent & compliance" section. Apps that never set
autoShowConsentDialog/never called ConsentManager.instance.showDialog
directly are unaffected — this was already an opt-in-by-config path, off
whenever autoRequestUmpConsent: true (the default) since round 5.…tagForUnderAgeOfConsent are now deprecated in favor of a unified ageRestrictedTreatment API, only reachable once this package can adopt google_mobile_…
RemoteSafetyDemoPage's cleanup:
destroy()/initialize() restore chain had no
.catchError, so a rejected Future there would surface as an
unhandled Zone error;dispose() before _wired ever flipped true, so
cleanup was skipped even though the in-flight call could still go on
to successfully rewire the live AdManager singleton.
Both fixed: the restore chain now swallows a failed retry, and the
in-flight apply call itself detects !mounted and performs the
restore if it succeeds after the page is already gone.doc/audit/audit_round43_admob_compliance.md): corrected
ump_consent.dart's doc comments, which overstated the durable "Privacy
Options" entry-point requirement as EEA/UK-only — it also applies to the
US-states/GPP consent message type; the code itself was already
region-agnostic, so this is a doc-only fix. Ad placement/density/
reward-granting and the rest of consent/privacy handling were
independently re-verified against Google's live (2026-09-18) policy
docs and found compliant, with one non-urgent note folded into
CLAUDE.md's existing pinning-wall section (tagForChildDirectedTreatment/
tagForUnderAgeOfConsent are now deprecated in favor of a unified
ageRestrictedTreatment API, only reachable once this package can adopt
google_mobile_ads 9.1.0+ — already blocked by the documented
Flutter/Dart floor, and non-urgent since Google keeps the legacy pair
working through 2026).Fixes for the round-42 audit findings (see doc/audit/audit_round42_consolidated.md and its per-reviewer reports for the full findings and severity rea
Fixes for the round-42 audit findings (see doc/audit/audit_round42_consolidated.md
and its per-reviewer reports for the full findings and severity reasoning).
NativeAdWidget's AppLovin branch never
included MaxNativeAdOptionsView, the mandatory privacy-information/
AdChoices-equivalent icon AppLovin's own native-ad integration guide
requires. Every AppLovin native ad impression from this SDK was
policy-non-compliant, unconditionally, on both platforms. Fixed by adding
it to the existing layout, positioned per AppLovin's own reference
example.8d8d990) had a narrow residual gap: when a
show-confirmation watchdog abandoned a cycle and a new cycle started
showing before the old cycle's real native callback finally arrived with
an empty/ambiguous creativeId, that late event could be misattributed
to the NEW cycle's caller instead of being discarded. Fixed by refusing
to start a new Interstitial/Rewarded show for 35s after a watchdog
abandonment (matching AppLovin's own documented "late by 10-30s"
callback ceiling), so by the time a new show genuinely begins, the old
cycle's straggler window has already closed.showRewardedAd's vipAutoGrant path
reused the onEarnedReward boolean to mean "VIP gets the perk, no ad
shown" as well as "the provider confirmed a genuine completed ad view."
The bundled example's "Watch ad for +10 coins" button fired this path
for VIP users with no ad ever requested. Doc comment now states this
explicitly; the example's button now discloses the no-ad case
("Claim +10 coins (VIP perk, no ad shown)").disableAppLovinCmpFlow: false's own doc
comment told a host to flip only that one flag to use AppLovin's own CMP
"instead of" UMP, but nothing checked whether autoRequestUmpConsent
(true by default) was also turned off — following that doc comment
literally ran BOTH consent flows concurrently on the same EEA user, each
able to silently overwrite the other's answer on AppLovin.
consentFootgunWarning now warns on this exact combination.DemoConfig set
only Android-valued AdMob test ad-unit ids with no iOS overrides for any
format — an iOS run of the example silently requested Android test units
for every AdMob surface and never actually validated AdMob on iOS. Added
Google's published iOS test ad-unit ids for every format.CompatibilityMatrix.minimum had no (iOS, AppLovin)
entry even though the adapter code handles it fine — self-inflicted
doc/CI gap, now closed._adUnitIdFootgunWarnings and the Google-test-id detector) only
covered banner/interstitial/appOpen/rewarded; rewardedInterstitialId,
mrecId, and nativeId now get the same coverage (only when
configured — these three are genuinely optional formats).RemoteSafetyDemoPage had no dispose()
despite globally rewiring the live AdManager's safety config; leaving
the page without tapping "Restore demo defaults" left that config
altered for the rest of the session. dispose() now runs the same
restore as a best-effort, fire-and-forget cleanup.NativeAdWidget doc example pairing a
120px custom height with the (higher-minimum) medium template; corrected
vipKeyValidator's doc comment, which implied null accepts every key
in all build modes (it only does in debug/profile — release rejects
every key); corrected a stale integration-test file count in CLAUDE.md;
corrected test/interstitial_rewarded_watchdog_test.dart's file-level
comment, which incorrectly claimed Interstitial/Rewarded have no
show-confirmation watchdog at all (they do — Round-7's AdSlot.beginShow
watchdog — the file's own test seams just don't arm it); documented
(not changed — reconfirmed as the existing, deliberate round-32 product
decision) the AVP2 bundle-binding fail-open on a PackageInfo read
failure.…so an accidental breaking change can no longer slip through unnoticed in an unrelated refactor.
Fixed (BLOCKER, real-device smoke test): every AppLovin fullscreen ad
(Interstitial, Rewarded, App Open) had its displayed/hidden/earned-
reward native callback silently discarded as "stale" — 100% of the
time, on real devices — even though the ad genuinely showed. Root
cause: the stale-cycle guard compared identical(ad, _interstitialAd)
(the loaded MaxAd Dart object), an assumption (never verified against
the real plugin) that applovin_max reuses the same object across a
show cycle. It doesn't — AppLovinMAX.createMaxAd deserializes a BRAND
NEW MaxAd from the platform channel on every single callback, so the
identity check was always false. Consequence in production: the 10s
show-confirmation watchdog always fired, reporting a genuinely-displayed
ad as "swallowed" — and for Rewarded specifically, a user who watched
the entire ad had their earned reward silently dropped. Revenue
correlation (T185, AdRevenueEvent.requestId) was broken by the same
root cause (an Expando<String> also keyed by ad-object identity) and
was always null for AppLovin fullscreen ads. Fixed by replacing both
mechanisms with MaxAd.creativeId comparison, which the real
network/mediation stack does vary between genuinely different ad
instances (trusting the callback when creativeId is empty/unavailable,
e.g. AppLovin's own test-mode creatives, rather than guessing). Found
during a real-device AdMob/AppLovin smoke test (Galaxy A50s, TECNO KJ7)
requested after publishing T202 — no code change had touched this path;
it had been silently broken since AppLovin support first shipped. 4 new
regression tests simulate the real plugin's actual per-callback object
semantics (existing tests never caught this because they reused one
MaxAd instance across a whole load→show→hide cycle); 3 existing
cross-cycle tests updated for the same reason.
New (T202): ConsentProvenanceJournal — append-only, tamper-evident
(SHA-256 hash chain) history of consent changes, exported from the
package barrel alongside ConsentProvenanceEntry. Opt-in —
AdConfig(enableConsentProvenanceJournal: true, ...), default false
(real SHA-256 hashing on every consent change is latency an app with no
legal-audit-trail need shouldn't pay for by default — a 5-parallel-
adversarial-review follow-up on this same feature also found it hangs
flutter_test's testWidgets() in specific file/test-ordering
combinations if wired unconditionally, so it stays off unless a host asks
for it). Reachable as AdManager().consentProvenanceJournal (nullable
until SDK init completes AND until enabled, same contract as
AdManager().vip); when enabled, every ConsentManager.set / .reset /
.showDialog call records an entry (source, policyRevision,
hasUserConsent, isAgeRestrictedUser, doNotSell, regionSignal).
Distinct from ConsentSettings (current state only) and
ComplianceReport (a point-in-time snapshot) — this is the change
history neither of those keeps.
AdManager().clearSdkData()'s default sweep,
under EITHER SdkDataErasureScope — some legal frameworks (GDPR Art.
17(3), CCPA) permit/require retaining proof that consent was
asked/received as a "legal basis defense" even after a user's general
erasure request. AdManager().clearSdkData(purgeConsentProvenanceJournal: true) removes it explicitly (also clears the live in-memory copy
immediately, same as VipManager's entitlement erasure does), as a
deliberate, separate decision from erasing VIP entitlements.ConsentManager.bootstrap/.set/.reset/.showDialog gained new
optional parameters (provenanceJournal, source, policyRevision) —
all additive with backward-compatible defaults, no behavior change for
an existing caller that doesn't pass them.append() calls could read a stale prevHash, producing a chain
verifyChain() wrongly flagged as tampered — now serialized via a
Future-chained queue; (2) a destroy()+reinitialize() cycle orphaned
the journal ConsentManager actually wrote to, leaving
AdManager().consentProvenanceJournal permanently stale — bootstrap()
now adopts a non-null journal on every call, not just the first; (3)
AdManager().clearSdkData(purgeConsentProvenanceJournal: true) — the
documented erasure escape hatch — didn't exist at the AdManager layer
(only on the internal, unexported AdPreferences), a compile error if
copied from the docs verbatim; (4) showDialog() recorded an entry but
could never be told a distinct source, making the SDK's own consent
dialog indistinguishable from a scripted set() call in the journal.Fixed (self-audit): with codex review unavailable all session, a
self-review of every commit from this session (5 parallel adversarial
reads, no confirmation bias — fresh agents, not the same context that
wrote the code) found and fixed 4 real defects:
InlineAdController.attach() (T201) crashed via its own debug assertion
on a legitimate Key-change remount (Flutter mounts the new State,
calling attach(), before disposing the old one, which calls
detach()) — the assert is gone; the last attach now simply wins, and
a stale detach from the old State is already a safe no-op.BannerAdWidget/MrecAdWidget's controllerRefresh() (T201) didn't
check _pausedByController the way every other reinit path already
does — refresh() while paused silently un-paused and reloaded. Now
gated the same way NativeAdWidget already was.RevenueIntegrityLedger (T145/T185)'s FIFO fallback match could
misattribute a late/orphaned revenue event to a DIFFERENT pending show
that has its own distinct requestId, when two shows of the same
(providerTag, type, placement) were pending at once — silently masking
a real revenue-integrity gap. The fallback now only ever considers
requestId-less pending entries, matching this class's own "resolved
EXACTLY — no guessing" promise for entries that do carry an ID.AdManager._destroy() (T183) discarded JourneyPrefetcher.dispose()'s
returned Future instead of awaiting it (that method's signature
changed from void to Future<void> in T183, but this one call site
was missed) — a pending persisted write could be silently dropped on
teardown. Fixed with the exact same capture-before-null-then-
await-later pattern _waterfallTuner/_selfHealingObserver already
use two lines above it in the same function.
Also documented (no code change, low severity / already-moot today):
compliance_signing.dart's concurrent-mint lock is keyed globally, not
per-FlutterSecureStorage instance; tool/api_surface.dart's API golden
walker doesn't see members of a plain Dart extension (only
classes/enums/mixins/extension types) — this package exports none today.Changed (T183): JourneyPrefetcher's rolling time-to-show averages
now persist across app restarts (persist: true, the new default) —
previously purely in-memory, so every cold start re-learned "how long
after this signal does the user actually see the ad" from zero. New
JourneyPrefetcher.ready (a Future<void>) completes once a prior
session's data has finished hydrating and this instance has started
listening for new events — notifySignal/averageTimeToShow never wait
for it themselves, same as every other on-device signal in this SDK.
dispose() is now Future<void> (was void) so a pending persisted
write isn't silently dropped on teardown, bounded by a 2s timeout the
same way WaterfallTuner.dispose already is. Pass persist: false to
opt out of the disk write entirely. Only ever stores a duration between
an app-defined signal string and an ad type — never the signal's own
content or anything personally-identifying.
New (T219): ConsentDialogStrings, CcpaOptOutStrings,
VipDialogStrings, and VipRedeemStrings (found mid-task — a separate,
~30-field string class for the full VipRedeemScreen, distinct from
VipDialogStrings' small redeem-confirmation-dialog subset) each gained
a named .en preset (identical to the plain default, just discoverable
by name symmetrically with .vi) and a resolve([Locale? locale])
static helper that picks .vi for a Vietnamese locale and .en
otherwise — pass Localizations.localeOf(context) to resolve against
the app's own configured locale, or omit it to fall back to the
device's own locale (works before any widget has built, e.g. directly
in main()). VipDialogStrings and VipRedeemStrings also each
gained a .vi preset for the first time — VipDialogStrings' Vietnamese
text previously only existed as a copy-paste example in a doc comment,
and VipRedeemStrings had no Vietnamese text anywhere at all. No
existing default changed — a host passing nothing still gets exactly
the same strings as before.
New (T217): Public API stability & deprecation policy, documented in
README.md — semver commitment, @Deprecated/@experimental usage, and a
minimum one-MINOR-version deprecation window before any removal.
Enforced by a new API golden test (test/api_golden_test.dart +
tool/api_surface.dart, dev-only analyzer/path dependencies): it walks
the fully resolved public export surface of applovin_admob_sdk.dart
(excluding @internal/@visibleForTesting seams) and fails on any
unreviewed diff from the checked-in test/goldens/public_api_surface.txt,
so an accidental breaking change can no longer slip through unnoticed in
an unrelated refactor.
New (T201): InlineAdController — an imperative refresh()/pause()/
resume()/status handle a host attaches to ONE BannerAdWidget/
MrecAdWidget/NativeAdWidget instance (new controller param on all
three, mutually exclusive with active), instead of juggling its own
active: bool state variable and forcing a rebuild every time it
changes, or reaching for AdManager's singleton methods (which have no
notion of "this one slot" and risk touching every other placement using
the same format). Every command only ever calls into that widget's own
existing gated methods — refresh() respects the same
consent/VIP/connectivity/cooldown gate an automatic reload already
does (silently skipped, never forced, while in cooldown); pause()/
resume() reuse the exact same path VisibilityDetector/route-away
already use for Banner/MREC, and dispose-and-reload for Native (no
auto-refresh ticker to merely suspend there). A command issued before
any widget has attached is remembered, not lost, and replays once the
next widget attaches. dispose() is idempotent. Fixed two related gaps
found while building this: returning to a route (didPopNext) used to
silently reload a Banner/MREC a host had explicitly paused via the new
controller if a real route push/pop happened in between (a pre-existing,
narrower version of the same gap for plain active: false — outside
this fix's scope, left for a follow-up); and a controller-driven
pause()/resume() invoked from outside any build phase or route
transition could leave a deferred addPostFrameCallback stuck
unscheduled.
New (T200): AdManager().clearSdkData({scope, confirmedEntitlementErasure})
— a scoped, privacy-safe data-erasure API. Unlike
AdPreferences.clearAllData() (still available, but now documented as
dangerous — it wipes the ENTIRE shared SharedPreferences instance,
including any key a host app or a different plugin stored in the same
namespace), this only ever removes keys the SDK itself owns, across
both storage backends it actually uses (SharedPreferences and
flutter_secure_storage for VIP entitlements).
SdkDataErasureScope.everythingExceptEntitlements (the default) clears
safety counters, consent settings, compliance/analytics history,
remote-config cache, and experiment id — VIP entitlements are left
completely untouched. SdkDataErasureScope.allIncludingEntitlements
additionally erases every VIP-entitlement key (VIP entries,
redeemed-key ledger, first-install grace flag, migration flags,
revocation cache, legacy GAID list) and requires
confirmedEntitlementErasure: true — passing that scope without it
throws an ArgumentError instead of silently downgrading, since this
permanently deletes VIP entitlements a user may have paid real money
for. When the SDK is already initialised, the live VipManager
instance is used so the running session's reactive VIP state updates
immediately, not just on the next restart.
Fix (T199): IncidentEntry.deltaMs could read negative when the
wall clock moved backward between two IncidentRecorder.record()
calls (an NTP sync, a manual clock edit, a timezone change) — a
confusing figure in a diagnostics timeline. deltaMs is now always
clamped to >= 0, and a new clockRolledBackMs field (int?, null
unless a rollback was observed) carries the raw negative delta so the
fact a clock jump happened is never silently hidden by the clamp.
Included in the JSON export (omitted entirely, not just null, when
there was no rollback — an old exported bundle without this field
decodes identically to a real "no rollback" entry).
Fix (T198): JourneyPrefetcher's opt-in routeObserver (T139) only
ever fired notifySignal on didPush — returning to a previous screen
(didPop) or a route being swapped in place (didReplace) silently
missed the journey signal entirely. Now fires on all three: a pop uses
the REVEALED previous route's name (the screen the user is now looking
at again), not the one being removed; a replace uses the new route's
name. No new API — same notifySignal entry point, same behavior for
a host that only ever sees didPush fire.
Fix (T197): FillRateMonitor, FillRateBaselineMonitor, and
BypassAuditTrail now throw a real ArgumentError for an invalid
constructor value (lowFillRateThreshold/regressionThreshold outside
(0, 1), rollingWindowSize/minSamples/maxEntries <= 0) instead
of relying on a debug-only assert (compiled out of release builds) or
— for BypassAuditTrail — nothing at all. Before this, an invalid
value in a release build either left the monitor silently useless
(never alerting, or alerting on almost everything) or, for a negative
BypassAuditTrail.maxEntries, crashed for real the first time its ring
buffer tried to trim. None of the values are silently clamped into
range — that would hide the same misconfiguration a different way.
New (T196): ConsentSettings.copyWith gained clearAskedAt/
clearCountry (bool, default false) — askedAt/country are
themselves nullable fields, so copyWith(askedAt: null) was previously
indistinguishable from "parameter omitted" and could never actually
clear either one once set (e.g. for a privacy/data-erasure flow).
Purely additive: every existing call site is completely unaffected.
Passing both a value and its matching clear*: true flag together
throws an AssertionError in debug mode (the two are contradictory).
Fix (T195): signComplianceReport/signJsonPayload could mint two
DIFFERENT Ed25519 signing keys when two calls raced on first use
(before any key was persisted) — both read no stored key, both minted
their own, and whichever write won silently stranded the other call's
already-returned signature under a key that would never again match
what's persisted, breaking the "same install, same public key across
every export" guarantee. A process-wide async lock now serializes the
mint-and-persist step: a concurrent caller shares the same in-flight
result instead of racing it, and a caller arriving after the lock
releases re-reads the (by-then persisted) key instead of minting a
second one.
Fix (T194): MonetizationArbitrator used to treat a genuinely
CONFIRMED $0 trailing eCPM (≥ warm-up samples, real average revenue is
exactly 0 — e.g. a run of pure house ads/cross-promo) identically to
"no evidence yet", always failing open to showAd and defeating the
point of the arbitrator for exactly the format it should most want to
veto. It now distinguishes the two internally: only a genuine absence
of qualified samples fails open; a confirmed $0 is treated as real
evidence below threshold, same as any other low eCPM (still subject to
the same maxVetoRate guardrail, still always invokes a registered VIP
likelihood estimator). estimatedEcpmMicrosFor's public return value
is unchanged (still int, still 0 for both cases) — this is purely
an internal decision-logic fix, not a public API change.
Fix (T193): AdManager().runIntegrationSelfCheck()'s per-format load
checks used to wait ONLY for a fresh AdLoadEvent, which a real
adapter never emits when it silently reuses an already-fresh, still-ready
cached ad (e.g. AdMobAdapter/AppLovinAdapter's "already ready/fresh
— keep it" short-circuits) — a genuinely healthy, preloaded slot timed
out and was reported as a false FAIL. The checks now look at the slot's
own state directly (readiness-first): an already-ready slot passes
immediately, an already-cooldown slot fails immediately with its last
error code, and only a genuinely in-flight load still waits, up to the
same timeout as before.
Fix (T192): DebugAdOverlay (debug-only, never shown to real users)
could crash with setState() or markNeedsBuild() called during build
if the panel was already expanded and a different, unrelated widget
synchronously mutated an AdSlot's state (or called
AdManager().initialize()) from its own initState()/build() — e.g.
a demo page preloading an interstitial in initState(), the same
pattern this package's own example app uses. The overlay's internal
ValueListenableBuilders now defer their rebuild to the next frame
instead of reacting synchronously.
New (T187): AdSafetySnapshot gained fullscreenClickThroughRate
— the exact fullscreen-only click/impression ratio the real CTR-anomaly
gate (round-39) evaluates, distinct from the pre-existing
clickThroughRate (which mixes in banner/mrec/native traffic and can
disagree with what actually triggered an anomaly). AdDiagnostics
gained pendingRevenueChecks (int?, null unless the host calls the
new AdManager().enableRevenueIntegrityLedger(...)) and
recentRevenueIntegrityIncidents (int, always computable from
AdManager().incidentRecorder) — so "why is revenue low today" answers
live in the same one-shot snapshot as the rest of AdDiagnostics.
New (T186): RevenuePanel (non-compact mode) now shows a
per-AdSlotType revenue breakdown below the existing session total —
each type present shows its own USD total and impression count, sorted
alphabetically. Same USD-only skip rule as the session total (a
non-USD AdRevenueEvent is never folded into either figure). Compact
mode (RevenuePanel(compact: true)) is unchanged — still a one-line
summary with no breakdown.
New (T185): AdShowEvent/AdRevenueEvent gained an optional
requestId (String?) — a per-load correlation ID both adapters now
stamp once and carry through to both events for that same ad instance.
RevenueIntegrityLedger matches a show to its revenue event EXACTLY by
requestId when both sides carry one, instead of only guessing by
(providerTag, type, placement) within a time window (the T150
heuristic — unchanged, and still the fallback whenever requestId is
null on either side: banner/mrec/native never set it, since neither
emits a matching AdShowEvent). Purely additive: requestId defaults
to null, no existing constructor call or event listener breaks.
New (T181): PlacementSpec.minIntervalOverrideMs — a per-placement
override for AdSafetyParams.minTimeBetweenFullscreenAds (the app-wide
"minimum time between two fullscreen ads" throttle), same override
contract as the existing frequencyCapOverride: applies for that
placement's show calls only, null (default) leaves the app-wide
throttle unchanged. Also threaded through canShowInterstitial/
canShowRewardedAd/canShowRewardedInterstitialAd (now accept an
optional placement parameter, default AdPlacement.unspecified —
existing callers unaffected) and the resume-triggered App Open flow
(matched against AdPlacement.splash, its default placement), so the
documented AdScreenState pre-check pattern and the automatic resume
path both see the same override the real show call does. A negative
minIntervalOverrideMs is rejected outright (falls back to the
app-wide value) rather than silently disabling the throttle — 0
remains the real, intentional "no throttle for this placement" value.
Internal (T180): the six opt-in feature enable*/disable* pairs
(arbitrator, fillRateMonitor, waterfallTuner, providerFailoverAdvisor,
selfHealingObserver, journeyPrefetcher) each repeated the same
"dispose old, assign new" body. Replaced with a shared generic
_swapDisposable helper. Public API (names/signatures) and behavior
are unchanged — verified by re-running the full test suite plus each
feature's own on-device integration test.
Fix (T177): MonetizationDigitalTwin.forecastDailyCap() used to treat
a negative hypotheticalDailyCap as silently meaning "uncapped", with no
documentation of that behavior and no test for it — a dev who passed a
negative number by mistake got a real-looking forecast for a policy they
never asked to model. Now asserts hypotheticalDailyCap >= 0 (stripped
in release builds, same cost/benefit as this internal debug/preview
tool's other guards); 0 remains a valid input, documented as
forecasting "fullscreen ads disabled entirely".
Fix (T213): tool/release_readiness_gate.sh's secret_scan stage
could report "release gate: secret passed" with a false PASS when rg
(ripgrep) was not installed — its rg call sat inside an if (...),
where bash's set -e does not apply, so rg's "command not found"
(exit 127) was indistinguishable from "no secret found". Confirmed live:
reproduced with rg genuinely absent from a clean subprocess PATH.
api_check had the same missing-dependency gap, though it already
failed (just with a confusing raw error) rather than silently passing.
Both stages now check for rg explicitly first, failing with a clear
diagnostic instead of either a silent pass or an unclear crash. Rewrote
the test suite to run the real script as a real subprocess (the old
test only grepped the script's source text for stage names — it could
not have caught this at all), with real pass/fail fixtures for both
stages, and removed a vacuous widget test (rendered hand-typed labels)
and device test (asserted a tautology) — this is a CI shell script with
no real device-specific behavior to prove.
Test (T207): the SDK lifecycle contract suite only exercised
initialize→load→show→background→destroy→reinitialize through
debugSetAdapter/debugConfig, bypassing the real initialize()/
destroy() path entirely, and never dispatched an app-lifecycle
transition at all (unrelated Text widget in the widget test; a bare
double-destroy() in the device test). Added a real end-to-end chain
test through AdManager().initialize() (routed via
debugAdapterFactory), dispatching background/resume through the real
WidgetsBinding.handleAppLifecycleStateChanged (not calling the
callback directly, which would still pass even if initialize() never
registered the observer), with an observable pause/resume side effect
instead of just "didn't throw"; a genuine mid-native-init destroy()
race (not merely after both concurrent calls settle); and both
concurrent callers verified to receive the real init result. Rewrote
the widget test around a real BannerAdWidget surviving destroy()
while mounted, and the device test to mirror the same real chain on a
physical device. No production code changed; it was already correct.
Test (T205): the VIP CLI secret-handling tests (vip_mint.dart/
vip_keygen.dart/vip_crl_mint.dart) only grepped the tool source for
certain substrings, which cannot observe the actual security property
(a real process's stdout/stderr never containing the private key).
Rewrote to spawn each CLI as a real dart run subprocess and inspect
its real stdout/stderr/exit code, and to verify a subprocess-minted
key/CRL actually round-trips through the SDK's own real
verifySignedVipKey/verifySignedCrl — not just "the CLI exited 0 and
printed something key-shaped". Also removed a vacuous widget test
(rendered and matched a hand-typed string, same fake shape as T215's)
and a device test that only checked an irrelevant, always-unset
environment variable — this is a dev-machine/CI CLI tool with no real
device-specific behavior to prove. No production code changed; it was
already correct.
Fix (T210): ConsentFallbackReason.offline/.staleRevision were
declared but never produced — every UMP failure was classified as
timeout/platformError even when the device had no connectivity, and
a fallback recorded under an old policy revision was treated as current
forever. Now: AdManager records offline when there is a REAL,
confirmed connectivity reading showing the device is offline (not the
optimistic pre-ready default — a requestUmpConsent() call from splash,
before the connectivity watch has resolved, still falls back to
text-based classification); ConsentManager reclassifies a persisted
fallback whose policyRevision is in the SDK's own UMP namespace
('ump-vN') but doesn't match the current kUmpPolicyRevision as
staleRevision on load, without touching a host's own ATT/custom-reason
fallback records. The hardcoded 'ump-v1' literal is now the shared
kUmpPolicyRevision constant. Also added ConsentManager.fallbackListenable
— recordFallback()/clearFallback() previously updated state with no
notification at all, so a host status widget could never react to it.
Replaced a vacuous widget test (rendered and matched a hand-typed
string) with one exercising the new listenable for real, and fixed the
device test file, which was missing
IntegrationTestWidgetsFlutterBinding.ensureInitialized().
Test (T211): the ad-load coalescing tests (unit, widget, device
integration) only asserted the slot's end state after concurrent load
calls, which is identical whether the manager's coalescing map actually
joined the calls or let every one of them through as a real native
request — AdSlot.beginLoad()'s own "already loading" guard already
masks the difference. Rewrote them to count real adapter invocations,
added a case proving a failed load's retry issues a genuinely new
native request (not a stale join), and a case proving
debugResetGuardState() (the same path destroy()/reinit use)
correctly invalidates an in-flight coalesced load so the next call
starts fresh. Also fixed the device test file, which was missing
IntegrationTestWidgetsFlutterBinding.ensureInitialized() and so never
ran through the integration_test device harness at all. No production
behavior change — _coalesceAdLoad/_invalidateCoalescedLoads were
already correct; only the tests proving it were not.
Fix (T215): CompatibilityMatrix.isSupported() — the check that CI's
compatibility gate is built on used to compare hardcoded constants
against themselves and could never fail, so a genuinely incompatible
Flutter/API-level bump in CI would have passed silently. Now compares
the real target against the declared, reviewed minimum matrix entry
for the same platform+provider: unknown combinations are rejected by
default (fail-safe), and — after a second audit round — an unapproved
newer Flutter version is rejected too (exact match on flutter, not
a >= floor), since a new Flutter release isn't proven compatible just
by being newer. apiLevel keeps a >= floor (a higher Android API
level is genuinely still supported). tool/validate_compatibility_matrix.dart
now reads the real running flutter --version --machine instead of a
hardcoded string. Also removed a vacuous widget test that only rendered
and matched a hand-typed string (CompatibilityMatrix has no UI
anywhere in the SDK).
Internal (T212): AdStressHarness/AdStressReport — briefly added
and exported publicly in this same "Unreleased" window, never actually
published — turned out to be a disconnected simulation with no
connection to AdManager/AdEvent/a real adapter at all. Rewritten to
genuinely exercise the SDK (real event bursts, real
initialize()/destroy() reinit cycles) and moved out of the published
package into the SDK's own test suite, since every real check it makes
needs test-only seams that can't legitimately live in lib/. No
behavior change for any real consumer: this was never shipped in a
release.
New (T174): AdSafetyConfig.canShowAppOpenOnResumePeek() — a
side-effect-free "would this pass right now" variant of
canShowAppOpenOnResume(), safe to call repeatedly (e.g. to drive UI)
without consuming the one-shot cold-start flag, the pending-resume gate,
or growing the rolling resume-timestamp window used for the rapid-resume
cap. Same split as the existing canShowFullscreenAd/
canShowFullscreenAdPeek pair.
New (T173): DebugAdOverlay's slot panel now shows a row for banner/
MREC/native too — previously only App Open/Interstitial/Rewarded had one.
Unlike those three (exactly one AdSlot each), banner/MREC/native are
keyed per widget instance, so the new row is a count-by-state summary
(Banner (2) ready=1 loading=1 fails=0) across every currently-mounted
instance rather than one line per instance.
Fix (T171): ProviderFailoverAdvisor(consecutiveFailureThreshold:),
WaterfallTuner(rollingWindowSize:), and IncidentRecorder(capacity:)
now validate their config parameter — a <= 0 value used to make each
class misbehave silently or crash instead of doing what a dev almost
certainly intended: consecutiveFailureThreshold <= 0 recommended a
provider failover immediately, with zero real failures; rollingWindowSize <= 0 silently disabled all sample tracking (0) or threw a RangeError
on the very first trim (negative); capacity <= 0 threw a RangeError
on the very first record() in release builds, where the class's old
bare assert is stripped. All three now log a SafeLogger.w warning and
substitute that class's own existing default instead.
Internal (T170): silenced the deprecated_member_use warning
flutter analyze raised on TickerMode.of in BannerAdWidget/
MrecAdWidget. Flutter's own replacement (TickerMode.valuesOf) doesn't
exist before v3.35.0-0.0.pre, and this package still declares
flutter: '>=3.27.0' in pubspec.yaml — switching now would compile-fail
for any consumer on an older Flutter, so the call itself stays and the
warning is suppressed with // ignore: deprecated_member_use instead
(the same workaround Flutter's own deprecation doc comment on of
recommends). No behavior change.
New (T168): App Open (and every other fullscreen ad path) could show
over a host's own custom overlay (e.g. a manually-inserted OverlayEntry
via Overlay.of(context).insert(...)) — AdScreenRouteLogger.isDialogOnTop
only tracks PopupRoutes pushed through a Navigator, and Flutter has no
public API for the SDK to hook an arbitrary host overlay automatically.
New opt-in API: markCustomOverlayOnScreen(bool value) /
customOverlayOnScreen (same pattern as markUmpFormOnScreen for the
native UMP form) — call with true right before inserting your overlay
and false right after removing it. Folded into the SDK's fullscreen
mutex the same way isDialogOnTop already is, so it blocks App Open on
resume AND canShowInterstitial/canShowRewardedAd/
canShowRewardedInterstitialAd.
Fix (T167): the consent dialog's ad-partners caption unconditionally
read 'Ad partners: Google AdMob, AppLovin', regardless of which network
the app is actually configured for — this SDK supports exactly one active
provider per app (AdMob XOR AppLovin, never both at once), so this
overstated who receives the user's data for every single integration, not
just a rare misconfiguration. ConsentDialogStrings.adPartnersLabel's
default now contains a {providers} token
(ConsentDialogStrings.autoProvidersToken), auto-substituted by
AdManager/ConsentManager with the network the app's AdConfig.provider
actually names. A fully custom adPartnersLabel (no token in it) is left
untouched; a custom template that reuses the token still gets real
substitution.
Fix (T166): CcpaOptOutToggle read AdManager().consentManager?.listenable
exactly once, at initState() — if this widget mounted before
AdManager().initialize() finished (e.g. shown during the first few
seconds of a cold start), consentManager was still null and the toggle
stayed permanently disabled for the rest of that mount, even once init
genuinely finished moments later. It now also listens to
AdManager().initRevision (the same general-purpose "SDK init state
changed" signal BannerAdWidget already uses for its own analogous
problem) and re-attaches to the real listenable the first time it becomes
available, so the toggle self-recovers without the host having to leave
and re-enter the screen.
Fix (T165): the fill-rate baseline monitor's day computation
(AdPreferences.recordFillRateBaselineSample/getFillRateBaselineHistory,
FillRateBaselineMonitor._baselineFor) used DateTime.now() (local time),
independent from the anti-fraud daily-cap counters' UTC-based, clock-
rollback-clamped _todayUtcClamped. A device timezone change could split
or merge a day's fill-rate samples differently than the anti-fraud
counters saw the same moment — a reporting/alerting inconsistency only,
never a cap-enforcement issue. All three now compute "today" through the
exact same UTC day key (AdPreferences.todayUtcClamped, a new public
wrapper). Also fixed a parsing bug this surfaced: pruning stored history
parsed a bare 'YYYY-MM-DDZ' string, which DateTime.tryParse silently
rejects (not valid ISO8601) — every stored day looked "too old" and was
discarded on every read-modify-write. Fixed to 'YYYY-MM-DDT00:00:00Z'.
Fix (T164): applyConsentToProviders only recorded consent as
actually applied to the providers when BOTH AdMob's and AppLovin's writes
succeeded, even for an app that only ever configures ONE via
AdConfig.provider. Now only the provider(s) config actually names need
to have applied; config == null keeps the original, more conservative
require-both behavior.
Fix (T163): SelfHealingObserver's dedupe (one recommendation per
(type, placement, recommendedProvider)) used a plain Set<String> that
never forgot a key — once a (format, placement) pair had been recommended
in one direction and later the other, a genuine LATER need to recommend
the exact same thing as the first time stayed silent forever, since that
key was already "seen". Now keyed to WHEN it last fired instead: a new
reobserveAfter parameter (default 7 days) lets the same key fire again
once enough time has passed, while still suppressing a near-duplicate in
the short term exactly as before. AdPreferences.getSelfHealingObservedKeys/
setSelfHealingObservedKeys (a plain key list, no timestamps) are replaced
by getSelfHealingObservedAt/setSelfHealingObservedAt (key → last-fired
timestamp) under a new pref key — the old data is left unread rather than
migrated, since it has no timestamp to migrate from.
Fix (T160): _lastShownPlacement (the map used to attribute a
revenue event to the placement the ad was actually shown under, rather
than whatever the adapter reports) was not cleared by
destroy()/reinit-without-destroy(), unlike every other per-session
field in _resetGuardState(). A stale placement from a session that just
ended could misattribute a revenue event the new session's adapter
reports before its own first show call. Now cleared in
_resetGuardState() alongside the other session-boundary resets there.
Fix (T158): AppLovin's onAdLoadFailedCallback disambiguated a
banner-vs-MREC failure purely by comparing the reported ad-unit id
against the configured bannerId/mrecId — a host configuring the SAME
ad-unit id for both (a plausible copy-paste mistake) made that comparison
always false, silently misrouting every MREC failure into the banner
branch (no data lost, just a slower — 30s watchdog instead of immediate —
recovery for the MREC side). initialize() now logs a warning if
bannerId == mrecId (both non-empty), and the failure callback falls back
to checking which registry actually has a load in flight to disambiguate
the genuinely-shared-id case, only defaulting to the pre-existing
banner-branch behavior when truly ambiguous (both loading at once).
Fix (T162): JourneyPrefetcher keys its internal timing map as
'$signal|${type.name}' but split every key on EVERY | when matching an
AdShowEvent back to its signal, assuming exactly 2 parts. A signal
string containing a literal | (a route name like /store|deal under
auto-mode, or any host-chosen signal string) produced a key with more than
2 parts, which then never matched — silently disabling time-to-show
tracking and prefetch timing for that signal forever, with no error.
Splits on the LAST | instead, correctly recovering the type suffix
regardless of how many | the signal itself contains.
Fix (T161): requestAttIfNeeded() had no guard against overlapping
calls — a caller triggering it twice before the first resolved (a bug, or
a user tapping a "grant permission" button twice) could present Apple's
native ATT prompt a second time, an undocumented and untested interaction.
A second call now joins the same in-flight request and resolves with its
result instead of triggering native requestAuthorization again.
Fix (T159): AdSafetyConfig.placementDailyCapReached used ?? between
maxPerPlacementAdsPerDay and maxPerPlacementAdsPerDayById when both had
an entry for the same placement — whichever map was checked first silently
won, ignoring a stricter cap configured in the other map, contradicting
both maps' own doc comments ("checked in ADDITION to"). The stricter
(smaller) of the two now always applies when both are set; unchanged when
only one is set, and capOverride still wins over both as before.
Fix (T157): BannerAdWidget's AdMob adaptive banner sized itself from
MediaQuery.of(context).size.width — the FULL SCREEN — regardless of what
container it was actually placed in, so a banner inside anything narrower
than the screen (a popup, a dialog, a sidebar, a split-screen pane)
requested a too-wide banner and overflowed its own container. It now
measures its real available width and requests a banner sized for that
instead, with MediaQuery kept only as the fallback for a genuinely
unbounded container (unchanged full-screen behavior otherwise). Also
catches a later resize of that container — a rotation, a split-screen
pane resizing, even one animated by an AnimatedContainer with no widget
rebuild at all — and reloads at the corrected width, debounced so a
continuously-animating container settles to a single reload instead of
reloading every frame. MrecAdWidget needed no change: its size is
fixed (300×250) regardless of the width value it passes internally.
Round-40 audit — 3 independent reviews (in-session Claude + codex + agy/ Gemini, each on an isolated repo copy) plus a rebuttal of two user-raised dou
Round-40 audit — 3 independent reviews (in-session Claude + codex + agy/
Gemini, each on an isolated repo copy) plus a rebuttal of two user-raised
doubts (docs accuracy, example-app completeness). 0 BLOCKER. 1 MAJOR found and
fixed:
IabStorage.usPrivacyOptedOut() and
_gppUsStatesOptedOut() used to return the first non-null GPP signal in a
fixed priority order (US National → California → other US states; and,
within the 19 states, section-ID order), even when that signal was false
(Did Not Opt Out). A CMP that legitimately populates more than one section
at once (e.g. a coarse national default alongside a jurisdiction-specific
override) could have a real opt-out in a lower-priority section
permanently shadowed by an earlier section's stale/default "did not opt
out". true now wins over false from any GPP tier/state; only null
(no section has a usable signal at all) falls through. Found by codex,
independently confirmed against source by both in-session Claude passes;
missed by agy.IABUSPrivacy_String was left OUT of the round-1 fix above —
still checked first and returned immediately if parseable, fully
authoritative even over a real GPP opt-out. That is the identical failure
shape round 1 fixed between GPP tiers: neither the legacy string nor GPP
carries a timestamp, so there is no basis to treat one as more definitive
than the other. The legacy string is now unioned into the same
true-beats-false rule as every GPP tier, not treated as a separate
short-circuit. The existing "legacy takes precedence" test's expectation
flipped (legacy N + GPP opted-out now correctly reads true, not
false) and a second test locks in the reverse direction (legacy opted
out + GPP N still true).CHANGELOG.md was missing "Published to pub.dev." on
the 2.9.17-2.9.19 entries — verified via the live pub.dev listing that
2.9.19 is in fact published and matches local source; only the note was
missing, not the content.RemoteAdSafetyProvider
(T88, RemoteSafetyDemoPage — destroys + re-initializes the SDK with a
live provider, then calls refreshRemoteSafetyParams() for real) and
AdReadinessSplashController (T94, ReadinessControllerDemoPage —
destroys + replays splash through the controller shortcut instead of the
manual flow). home_page_test.dart updated (19 → 21 tiles) with new
navigation tests for both.destroy()/initialize() (or a second
splash route) before the first one's await resolved — the button only
disabled once everything had already finished. Added a _busy guard on
both, disabling the button synchronously on the first tap and resetting
in a finally (with a mounted check). Confirmed fixed with two
double-tap regression tests run for real on a Pixel 7 Pro (see below) —
the fix caught the exact race the review named, no theoretical-only fix.RemoteSafetyDemoPage's "Apply
provider" mutates the app's live AdSafetyConfig globally, with no way
back short of restarting the app — every other demo screen visited
afterward would silently inherit the simulated remote values. Added a
visible warning card and a "Restore demo defaults" button that detaches
the provider and re-initializes on DemoConfig's own defaults.RemoteSafetyDemoPage and ReadinessControllerDemoPage
each gained a @visibleForTesting invocation counter
(debugApplyCallCount/debugReplayCallCount, incremented only past the
_busy guard); both double-tap tests now assert the counter is exactly
1 on top of the end-state checks._applyProvider,
_pushUpdate, _restoreDefaults, and _replay used finally without a
catch — a destroy()/initialize()/refresh failure would surface as an
unhandled async error with the status stuck on "Applying.../Fetching...".
All four now catch and surface the failure in the UI (status text or a
SnackBar) instead.AD_PROVIDER_ADMOB=true):
6 integration_test/ files (7 tests total), each run individually for
real and passing — round40_gpp_shadow_test.dart (2 tests: the R40-A
fix's cross-tier and within-states shadowing scenarios, off the real
platform preference store, not a mock), round40_remote_safety_demo_test.dart
(wires a real provider, drags the slider, calls
refreshRemoteSafetyParams(), and asserts AdSafetyConfig's live
snapshot actually changed to the pushed value),
round40_remote_safety_demo_doubletap_test.dart (asserts
debugApplyCallCount == 1), round40_remote_safety_demo_restore_test.dart
(round 2, R2-03 — confirms "Restore demo defaults" actually puts the live
AdSafetyConfig back on DemoConfig's own default, not just that the
button doesn't crash), round40_readiness_controller_demo_test.dart
(destroys + replays splash through the real controller, confirms
onReady fires and the SDK is initialised again), and
round40_readiness_controller_demo_doubletap_test.dart (asserts
debugReplayCallCount == 1).integration_test/ files (7 tests) added. 2 known MAJOR-tier trade-offs re-confirmed unchanged from round 39
(Android trial/VIP replay via reinstall/clear-data — no-backend design,
documented in README's VIP section).test/iab_storage_us_states_parallel_test.dart still asserted the
pre-R40-A expectation (Virginia's earlier, non-null false beats Rhode
Island's true) — this session had re-run test/ad_manager_core_test.dart
directly after every follow-up fix but never the SDK's full test/ suite
again after round 1's _gppUsStatesOptedOut() change, so this file's own
contradiction with the round's own fix went unnoticed until an
independent reviewer ran the whole suite. Updated to expect true
(Rhode Island's real opt-out wins), reusing the same already-verified
fixtures. Full suite now 1662/1662, 0 failures (previous full runs
this round showed 1 failure each time, but a different, genuinely
order-dependent pre-existing flake in a timing-sensitive test unrelated
to this round — see doc/audit/audit_round40_consolidated.md's
Addendum for detail on telling the two apart).true from any GPP tier/state/legacy
string over false from any other is intentionally fail-closed for
privacy — a stale signal can still force an opt-out even if it is no
longer the user's current one. That is the accepted tradeoff (a
wrongly-honored opt-out costs some monetization; a wrongly-ignored one is
a compliance risk), not an oversight.RemoteSafetyDemoPage._applyProvider()
and _restoreDefaults() passed AdManager().initialize() an
onComplete callback that discarded its success flag — a legitimate
onComplete(false, gaid) (init failing without throwing) still fell
through to the success branch, claiming "Provider wired"/"Restored" while
the SDK was actually left uninitialised right after destroy(). Both now
capture success and branch on it, showing a failure status instead. A
real initialize() failure is network-dependent and not reliably
forceable from a test, so debugForceApplyResult/
debugForceRestoreResult (@visibleForTesting, round 4 follow-up) skip
the real destroy()/initialize() call and inject the outcome directly,
isolating just this branch's UI handling — example/test/ remote_safety_demo_page_test.dart (2 new tests) exercises both failure
paths deterministically; the real call's happy path stays proven
on-device by the existing round40 integration tests.usPrivacyOptedOut()'s doc comment still
described the pre-R2-01 "legacy is authoritative" behavior a few
paragraphs above the R2-01 note that superseded it — reworded as an
explicit historical note so a future maintainer can't restore the old
precedence by pattern-matching the wrong paragraph.codex, isolated repo
copy each time, no shared context with this session or with each other):
round 1 scored 7/10 (GPP fix itself sound; flagged the double-tap race,
now fixed, and said example test coverage didn't yet match what this file
claimed). Round 2 scored 6.5/10 and blocked production on R2-01 (above,
fixed) plus R2-02/R2-03 (above, fixed). Round 3 scored 7/10 and blocked
production on R3-01 (above, fixed) plus R3-02 (above, fixed). See
doc/audit/audit_round40_consolidated.md's Addendum for all three
reviews in full and what shipped in response to each.Round-39 audit (4 independent reviewers: codex, Gemini, and two independent Claude passes that disagreed on one finding — see doc/audit/audit_claude.m
Round-39 audit (4 independent reviewers: codex, Gemini, and two independent
Claude passes that disagreed on one finding — see doc/audit/audit_claude.md
for how that got resolved by writing a regression test instead of taking
either side's word for it). Fixes 4 MAJOR and 3 MINOR bugs, all with
regression tests, plus a real product gap:
setConsent() race the round-38 epoch guard didn't reach: the
AppLovin-only COPPA re-initialisation branch wrote to the real native SDK
unconditionally, so an older, superseded consent-toggle call could still
land after a newer one.ConsentManager's own disk write (_persist()) wasn't serialized: two
overlapping set()/reset() calls' real platform-channel writes could
finish out of order, leaving a stale value on disk that silently reverted
the user's actual choice the next time the app launched.runZonedGuarded, matching the pattern already used at
init time.visibility_detector dependency), and gained a manual active
parameter — required specifically for a bare IndexedStack bottom-nav tab,
which the automatic detector genuinely cannot see (Flutter never calls
paint() on a non-current IndexedStack child, and that's exactly what
the detector's re-evaluation depends on — see BannerAdWidget's class doc
comment).AdRetryPolicy.jitterFraction near
1.0 could collapse backoff to near-zero (now floored at 10% of the base
delay); the example app never demonstrated VIP-code revocation (CRL) —
it now has a demo button.A second, independent review pass of this round's own fixes (requested separately, after the above landed) found 3 more real issues in them:
active param on BannerAdWidget/MrecAdWidget was
ignored at the very first mount (only didUpdateWidget checked it) — the
primary IndexedStack-tab-not-at-index-0 use case the param exists for
still loaded an ad on a hidden tab. Fixing it surfaced 3 more independent
init paths (a destroy→reinit retry, and two consent/VIP-change listeners)
that also didn't know about active and needed the same guard.initialize() call sat outside its
own epoch guard, so a superseded call could still trigger a redundant
extra SDK re-initialisation.ConsentManager.resetForTest() didn't reset two test-only
static fields, risking cross-test pollution.A full re-run of all 65 on-device integration test files (Pixel 7 Pro) also caught a real gap the CTR counter split (above) introduced: one integration test still used the old banner-based trigger to exercise the CTR-anomaly event stream, which silently stopped working once banners no longer feed the fullscreen-only counter — fixed to use the new trigger shape.
Verified: 1656/1656 unit/widget tests, flutter analyze clean, no
regressions, plus 65/65 on-device integration test files run for real on a
Pixel 7 Pro (63 genuine passes; 2 failures are a pre-existing, documented gap
— no real AppLovin SDK key is committed in this repo — unrelated to this
round). See doc/audit/audit_round39_consolidated.md. Published to
pub.dev.
Round-38 audit (4 independent reviewers, 2 further re-audit rounds). Fixes 2 MAJOR bugs: an AppLovin native ad that failed to load once stayed permane
Round-38 audit (4 independent reviewers, 2 further re-audit rounds).
Fixes 2 MAJOR bugs: an AppLovin native ad that failed to load once stayed
permanently blank for the widget's remaining lifetime (its retry timer
never disposed the stale, still-errored bundle); and a setConsent()
race where an older, delayed call could silently re-apply a stale
consent value to the real AdMob/AppLovin SDK after a newer overlapping
call had already applied the correct one — the root cause turned out to
be two layers deep (ConsentManager's own persist-then-apply cycle, used
by every set()/reset()/showDialog() call, not just AdManager's),
found only by testing on real hardware, not mocks. Also: the debug
overlay's fill-rate monitor no longer stays latched onto a disposed
monitor after a destroy()+initialize() cycle; VIP redeem's generic
error handler no longer leaks a raw exception message; 19 sequential GPP
US-state reads now run in parallel (same precedence preserved); and
dispose()-while-showing on AppLovin is now logged as diagnosable (no
programmatic dismiss API exists to fully fix it). Verified: 1640/1640
unit/widget tests, flutter analyze clean, 2 new on-device integration
tests passing for real on a Samsung device, full app build+install+smoke
run with no crashes. See doc/audit/audit_round38_consolidated.md. Published
to pub.dev.
Round-37 full re-audit (dual-provider correctness, offline/online resilience, ad lifecycle, VIP, consent-for-every-country, AdMob/AppLovin policy comp
Round-37 full re-audit (dual-provider correctness, offline/online
resilience, ad lifecycle, VIP, consent-for-every-country, AdMob/AppLovin
policy compliance). Fixes a BLOCKER: reloading a fullscreen ad in the
background could dispose the ad currently on screen if its cache looked
stale, killing its dismiss callback mid-show. Also: full GPP coverage
(US National + California + 19 US states, previously only partial),
a Backoff integer-overflow that silently collapsed exponential backoff
to its base delay after ~51 consecutive failures, a daily/placement ad
cap that had no protection against the device clock being wound
backward, a double-tap that could stack two safety dialogs on top of
each other, three show*() paths that left the host with an unhandled
exception and no callback if the underlying adapter threw, and a
consent-dialog visual asymmetry between Allow/Reject flagged by EDPB
deceptive-design guidance. A follow-up independent review then found the
new exception-handling fix could itself double-invoke the host's own
callback if that callback threw — fixed with a delivery-tracking guard,
applied to all four fullscreen show paths including the pre-existing
showRewardedAd(). Verified before publish: flutter analyze clean,
1632/1632 unit/widget tests, three independent review passes (9.5-9.8/10),
and a full on-device smoke test on a real Samsung S24 Ultra covering every
fix including a real interstitial surviving the reload race and a real
tap dismissing it. See doc/audit/audit_round37_consolidated.md for the
complete finding list and scoring rationale. Published to pub.dev.
Published to pub.dev. Round-35/36 audit — line-by-line source review of lib/src/ (not a diff-since-last-round), split across 4 parallel independent re
Published to pub.dev. Round-35/36 audit — line-by-line source review of
lib/src/ (not a diff-since-last-round), split across 4 parallel
independent readers, each cross-checked against real code before being
accepted; a follow-up independent adversarial review of the fix diff
(8.5/10 → gaps closed → 9.5/10, confirmed unchanged by a second
independent pass in round 36). Found and fixed 3 real bugs. Verified
before publish: 1581/1581 unit/widget tests pass (TDD red→green
throughout), flutter analyze clean, and a full 48-file on-device
integration run on a real TECNO KJ7 (Android 14, arm64) — 46/48 pass, the
2 failures both pre-existing and unrelated to this release (missing local
AppLovin credentials; a documented vip_redeem_flow_test timing flake
tracked since round 34). See doc/audit/audit_round35_consolidated.md and
doc/audit/audit_round36_consolidated.md for the full record.
Fixed:
AdCrashGuard.isSdkAttributable() matched this SDK's package name against
the entire stack trace, not just the throw site. Because the SDK is
always on the stack immediately beneath any host ad callback it invokes
(onReward, onAdDismiss, onAdClicked, ...), a genuine bug thrown
inside a host app's own callback was misattributed to the SDK and
silently swallowed by installAdCrashGuard() — logged only to this SDK's
internal tag, never reaching the host's own Crashlytics/Sentry. Now checks
only the trace's first (throw-site) frame.ConsentManager.bootstrap() silently discarded a second call's prefs
argument when a singleton already existed, with no signal that it had
happened. Now logs a warning via SafeLogger when a second call actually
passes a different AdPreferences instance than the one already in use.JourneyPrefetcher (opt-in, off by default) — a single successful
AdShowEvent credited every pending journey signal for that ad type,
not just the one that actually preceded it. Two different signals pending
for the same slot type at once (e.g. "levelStarted" and
"screenEntered" both awaiting an interstitial) had their time-to-show
samples conflated. Now resolves only the most recently fired pending
signal for that type.Docs: replaced hardcoded, release-to-release-drifting test-count and
version numbers in CLAUDE.md, doc/feature.md, doc/README_TESTING.md,
and doc/architecture.md with pointers to this file's top entry, so they
stop going stale the way README.md's GPP section did before round 33.
Published to pub.dev. Verified before publish: 1571/1571 unit tests, full 48-file Android integration suite on real hardware (46 pass, 1 skip needing
Published to pub.dev. Verified before publish: 1571/1571 unit tests, full 48-file
Android integration suite on real hardware (46 pass, 1 skip needing an optional
extra dart-define, 1 self-documented AppLovin-credentials gap — see
doc/audit/audit_round33_consolidated.md), and the 4 iOS integration tests
covering this release's changed code (banner/MREC/native/GPP) passing clean on
a real Simulator. Two other iOS integration tests were also checked and confirmed
failing identically on the pre-release baseline (bisected) — pre-existing, not a
regression from this release.
Round-33 audit follow-up — 3 independent agents (codex, agy/Gemini, claude)
re-audited 2.9.14 and, again, disagreed; see
doc/audit/audit_round33_consolidated.md. Two real gaps closed this round,
one documented as a known limitation rather than "fixed" because it isn't
fixable from this side of the dependency boundary.
Fixed:
IabStorage.usPrivacyOptedOut() only read the legacy IABUSPrivacy_String
— a CMP that writes only the newer GPP US National section (some newer
US-state CMPs do) read as "no signal" instead of "opted out." Now falls
back to decoding the GPP USNAT (section id 7) Core Segment's SaleOptOut/
SharingOptOut fields when the legacy string is entirely absent; the
legacy string, when present, is still authoritative and unchanged. Scope
stays deliberately narrow — no attempt to decode the GPP header's own
section-id list or any section beyond US National — same reasoning m10
(round 5) gave for not touching GPP at all: mis-parsing a privacy signal is
worse than not reading one, so only the one well-specified section this SDK
actually needs is decoded. Test fixtures are real strings generated by IAB
Tech Lab's own reference encoder (@iabgpp/cmpapi), not hand-derived —
cross-checked this SDK's independent bit-reader against the authoritative
implementation rather than trusting hand arithmetic on a legal-consent code
path.onAdRevenuePaidCallback had no guard against
a late callback for an ad-view/native instance the widget has since moved
on from (a reload handing out a new adViewId, or the instance being
disposed outright) — unlike AdMob's equivalent callbacks, which have
carried an identity guard since round 6. Plausible-but-unconfirmed finding
from round 33 (no device reproduction, no test seam for the underlying
third-party platform views); added the same class of guard preventively:
banner/MREC now re-check the current live adViewId before recording, and
_AppLovinMaxAdView/_AppLovinMaxMrecView are now keyed by adViewId so
Flutter tears the old widget down properly on reload instead of reusing its
element; native now checks the adapter's existing disposed-instance
tombstone (AppLovinAdapter.isNativeInstanceDisposed, newly public) before
recording.Documented, not fixed (known limitation): applyConsentToProviders()'s
AppLovin branch (ad_consent.dart) cannot actually confirm its platform
writes succeeded — AppLovinMAX.setHasUserConsent/setDoNotSell are
fire-and-forget void methods in the applovin_max package with no Future
to await, so the surrounding try/catch cannot see a platform-channel
failure the way the (properly awaited) AdMob branch can. This is a dependency
ceiling, not something fixable in this SDK's own code; see the "Known
limitations" section of README.md and doc/AD_PROMPT_FLUTTER.MD step 4.11
for the full explanation and who this affects (apps with real EEA/UK/
California traffic on AdProvider.appLovin).
Round-32 follow-up, part 2 — the AppLovin banner/MREC/native revenue-event gap flagged in 2.9.13's changelog as a separate follow-up.
Round-32 follow-up, part 2 — the AppLovin banner/MREC/native revenue-event gap flagged in 2.9.13's changelog as a separate follow-up.
Fixed:
AdRevenueEvent
from onAdLoadedCallback (fill time) via the shared static
WidgetAdViewAdListener — the same class of bug round-31 already fixed
for AdMob's banner/MREC (onAdImpression vs onAdLoaded). Moved to each
widget's own onAdRevenuePaidCallback on its per-instance MaxAdView
listener — AppLovin's real impression-with-revenue signal for this ad-view
API (there is no separate pure "displayed" callback here).NativeAdWidget) had no revenue signal wired at all
— 0 AdRevenueEvent, 0 impression count, for the entire lifetime of the
SDK on this format, despite NativeAdListener supporting
onAdRevenuePaidCallback same as the ad-view listeners. Wired it.The actual field-mapping logic (which MaxAd fields feed which
AdRevenueEvent field, the revenue <= 0 skip) is pulled into a new shared
appLovinRevenueEvent() (applovin_ad_revenue.dart), reused by the
fullscreen formats' existing _emitRevenueIfPresent too — one source of
truth instead of four near-identical copies.
Known test gap, called out rather than silently left implicit: the
callback wiring (does MaxAdView/MaxNativeAdView actually invoke
onAdRevenuePaidCallback with real data) has no test seam in this repo —
both are third-party platform views, and this package's test suite has
never simulated their native channel. The pure mapping logic has a direct
unit test (applovin_ad_revenue_test.dart); the wiring itself needs
on-device verification, done separately (see the round-32 audit doc for the
device-verification log).
Suite: 1562/1562 pass. flutter analyze: 0 issues.
Published to pub.dev — nhảy thẳng từ 2.9.6 (5 version 2.9.7-2.9.10 chưa từng lên pub.dev). Trước khi publish đã verify thật: pod install pinning wall
Published to pub.dev — nhảy thẳng từ 2.9.6 (5 version 2.9.7-2.9.10
chưa từng lên pub.dev). Trước khi publish đã verify thật: pod install
pinning wall (AppLovinSDK resolve đúng 13.5.0), build+chạy example/ thật
trên iOS Simulator và Android thật (TECNO SPARK Go 2024) — bao gồm form
UMP EEA thật (không priming dialog), xác nhận không lặp lại sau cold
restart.
Round 31 — full re-audit từ đầu của TOÀN BỘ lib/src/ + example/ (lần
đầu ai đọc riêng example/), ưu tiên sâu AdMob provider. 9 agent song
song, không tin báo cáo cũ, đối chiếu policy Google/Apple mới nhất khi
cần. Tìm 2 BLOCKER + ~20 MAJOR + ~6 MINOR thật; 2 finding khác hoá ra
false positive sau khi tự verify sâu (ghi lại dưới, không "sửa" bằng giải
pháp giả). Mọi fix RED→GREEN mutation-verified. Suite 1553/1553 pass.
BLOCKER:
AdMobAdapter.initialize() gọi updateRequestConfiguration
(mang cờ COPPA tagForChildDirectedTreatment/tagForUnderAgeOfConsent)
SAU MobileAds.instance.initialize() — ngược thứ tự Google Flutter
Targeting guide yêu cầu, và ngược chính pattern SDK đã tự sửa đúng cho
AppLovin (MJ1). Mediation network con (Meta/Unity...) init bên trong
initialize() có thể gửi request đầu tiên thiếu cờ trẻ em. Đổi thứ tự.IabStorage.tcfAllowsPersonalisedAds() không phân biệt được
"chưa từng có TCF session" (an toàn, mặc định true) với "platform
store đọc lỗi" (nguy hiểm, từng mặc định true giống hệt) — nếu đường
đọc TCF trên iOS (chưa từng verify trên máy thật, CI chết từ
2026-08-09) âm thầm lỗi, tái phát đúng BLOCKER round-6 (coi obtained
là đủ để bật personalized ads dù EEA user đã từ chối). Đọc trực tiếp
qua _open(), phân biệt store thật sự không đăng ký (test-only,
không đổi hành vi) với lỗi đọc thật (fail-closed).Core (ad_manager.dart, ad_safety_config.dart, remote_ad_safety_provider.dart, ad_preferences.dart):
disableFillRateBaselineMonitor() copy-paste sai từ
destroy(), tắt luôn cả 3 tính năng opt-in khác không liên quan
(WaterfallTuner/SelfHealingObserver/JourneyPrefetcher)._attachFullscreenDismissWatchers() thiếu
rewardedInterstitialSlot — format này (AdMob-only) vẫn dùng mốc
dismiss "brittle" cũ (stamp lúc earn-reward, không phải lúc video thật
đóng), App Open có thể bounce-back ngay sau RewardedInterstitial.refreshRemoteSafetyParams() thiếu try/catch quanh
merge override (khác initialize() có), và posInt() throw
UnsupportedError với Infinity/-Infinity (d == d.truncateToDouble()
đúng cho Infinity) — payload remote hỏng có thể crash. Thêm try/catch +
sửa root cause (isFinite check).DateTime.now().toIso8601String()), không như mọi rolling window khác
trong file (đều dùng millisecondsSinceEpoch tuyệt đối) — đổi múi giờ
thiết bị (không cần chỉnh đồng hồ) là reset counter tuỳ ý. Đổi sang UTC.ctrComponent của risk score)._maxSuspiciousPause
(24h) không bao giờ đạt tới (tối đa thực tế 8h) — nâng clamp lên 6.hoursSince — đồng hồ bị vặn lùi (không cần tiến, khác MJ9) làm hệ số
decay > 1, KHUẾCH ĐẠI violation count thay vì giảm. Thêm math.max(0, …).unitDouble('suspiciousCtrThreshold') chấp nhận 0.0
— backend serialize thiếu field thành 0 sẽ khiến MỌI click bị coi là
bất thường. Thêm sàn > 0.0.AdMob adapter:
onAdImpression
thật — dùng onAdLoaded (fill, không phải impression thật) làm proxy,
không bao giờ emit AdImpressionEvent cho 3 định dạng này, và làm méo
mẫu số CTR-fraud detection. Wire đúng callback thật.onAdOpened cho click, native dùng
onAdClicked — hai sự kiện được Google tài liệu hoá là khác nhau.
Thống nhất về onAdClicked cho cả 3.AppLovin adapter:
onAdHiddenCallback tự
tài liệu là "unreliable, có thể trễ 10-30s", late callback từ cycle cũ
có thể set _displayConfirmed/resolve nhầm cycle mới. Thêm _appOpenAd
maxSameNetworkShowsPerWindow, networkFatigueWindowMs) — network-
fatigue guard không remote-tunable được dù mọi field số khác đều có._emitRevenueIfPresent (nói revenue
đến từ load callback — thực ra là display/impression time, hành vi
đúng, chỉ comment sai).incrementDailyAdCount/incrementPlacementDailyCount thiếu write-chain;
widget listener thiếu _teardownStarted guard) hoá ra false positive
— lần lượt vì SharedPreferences legacy cache mutate đồng bộ (không có
race thật trong Dart đơn luồng) và vì _bannerDisposed/_mrecDisposed
đã tự bảo vệ qua scratch-object fallback. Không sửa; ghi lại lý do +
test pin đúng hành vi hiện tại để tránh "sửa" lại nhầm sau này.VIP:
RedeemedKeyLedger._writeChain là field instance-level
(không static) — mirror đúng bug pattern VipManager._saveQueue đã sửa
ở round-10 nhưng KHÔNG áp dụng ở đây. AdManager không truyền lại ledger
cũ khi destroy()+initialize() lại → 2 instance ghi đè Keychain lên
nhau → 1 kid đã redeem có thể "biến mất" khỏi ledger bền vững, cho phép
redeem lại sau reinstall trên iOS. Đổi sang static, mirror chính xác
_saveQueue's _savesInFlight pattern.SharedPreferences, không mã hoá — nhưng KHÔNG thêm checksum: chính
lịch sử audit của repo này (M6, _vip_entries_store.dart) đã chứng
minh checksum không-khoá với salt nằm trong source code published lên
pub.dev không phải bảo vệ thật trước đúng kẻ tấn công cần chặn. Ghi rõ
đây là giới hạn chấp nhận được của kiến trúc "không backend", cùng tầng
rủi ro (cần root/trích xuất vật lý) với các giới hạn khác đã biết._first_install_guard.dart's bypass-result matrix thiếu
1 dòng — genuine first launch trên máy MỚI restore từ iCloud backup của
máy cũ đã nhận grace bị false-positive block. Trade-off sản phẩm thật,
không có accessibility value nào chặn được cả 2 hướng cùng lúc.Widget:
AdReadinessSplashController's buffer-dialog
onComplete chỉ check ctx.mounted, không check _navigated — hard-cap
timer có thể fire (điều hướng sang Home) TRONG LÚC buffer 1s vẫn đang
đếm, route splash cũ vẫn mounted trong lúc exit-transition → App Open
có thể show SAU KHI đã điều hướng. Thêm check _navigated.NativeAdWidget's retry-after-30s listener
(nativeHasError) chỉ subscribe MỘT LẦN ở initState — sau bất kỳ chu
kỳ dispose/revive nào (consent gate đóng-mở lại, rất phổ biến) bundle
mới được tạo với notifier mới, listener cũ chết im lặng, quay lại đúng
bug round-29 tưởng đã fix. Track + re-subscribe đúng notifier hiện tại
mỗi lần _initNative() chạy.RouteAware, không phủ được
bottom-nav dựng bằng IndexedStack/Visibility(maintainState: true)
(không có Route change nào để RouteAware thấy) — ad ở tab ẩn tiếp tục
refresh/request nền, đúng loại vi phạm policy "requesting ads that
aren't visible". Thêm TickerMode.of(context) detection (bắt được
Visibility(maintainState: true)/CupertinoTabScaffold, KHÔNG bắt
được IndexedStack trần — ghi rõ giới hạn còn lại + workaround trong
doc comment của cả 2 widget).DebugAdOverlay's stream subscribe chỉ thử 1 lần ở
initState — mount trước khi enableFillRateBaselineMonitor() chạy
thì mất tín hiệu alert vĩnh viễn. Retry mỗi build() (rẻ, chỉ debug
tool).Monetization (chỉ tài liệu hoá, không đổi hành vi):
WaterfallTuner.recommendation()/SelfHealingObserver không bao giờ
có thể trả về non-null trên thiết bị thật, vì kiến trúc 1 install =
1 provider cố định suốt vòng đời khiến otherKey luôn rỗng. Đã opt-in
sẵn (off theo mặc định) — ghi rõ giới hạn thật vào doc comment của cả
2 class + 2 method enable* trên AdManager, để host không kỳ vọng
sai tính năng "flagship" này sẽ tự kích hoạt.Consent/GDPR/COPPA/CCPA:
AdScreenRouteLogger/App-Open-resume-guard không thấy được. Tái dùng
chính xác markUmpFormOnScreen() (ref-counted, backstop 15 phút) thay
vì xây cơ chế song song; release gắn vào future GỐC (không timeout) để
tránh đúng bug UMP form từng gặp (timeout Dart-side không đóng dialog
native thật).isAgeRestrictedUser: true (COPPA) nhưng để umpTagForUnderAgeOfConsent ở mặc định false
trong khi UMP flow vẫn chạy — form UMP chuẩn (206 đối tác) có thể hiện
cho audience tự khai là trẻ em. Thêm coppaUmpMismatchWarning()
(pure + static, cùng hợp đồng consentFootgunWarning).umpsdk" — package đó không tồn tại, và cả 2 thực
ra ĐÃ được SDK tự triển khai (requestUmpConsent()/requestAtt()).CcpaOptOutToggle — widget "Do Not Sell or Share My
Personal Information" cho CCPA/CPRA (Cal. Civ. Code §1798.135), vốn yêu
cầu là lựa chọn end-user thực thi được, không phải hằng số dev hardcode
như consent_dialog.dart's binary dialog vẫn đúng khi giữ nguyên cho
COPPA/GDPR. Thêm AdManager().setDoNotSell(bool)/.doNotSell (máy móc
đã có sẵn từ trước — AdConsent.doNotSell đã flow đúng tới cả 2
provider + persistence; chỉ thiếu entry point tiện lợi + UI thật).Example app (example/lib/main.dart) — lần đầu có ai đọc riêng qua 31 round:
mrecId dùng chung ad-unit-id Native Advanced với
nativeId — MREC thực ra chỉ là banner ở size khác, phải dùng Banner
test ID. Trang demo MREC không load được creative test khi build với
AD_PROVIDER_ADMOB=true (chính path CI dùng).AdMobConfig thiếu rewardedInterstitialId — trang
demo riêng (round-27 làm để đóng coverage gap cho định dạng AdMob-only
này) không bao giờ có thể show ad thật; test integration hiểu nhầm kết
quả "chắc chắn fail" thành "flaky do fill/timing".AppOpenDemoPage (StatelessWidget) dùng context sau
callback bất đồng bộ (loadAppOpenAd) không check context.mounted —
mọi chỗ khác trong cùng file đều có guard này, đây là code mẫu dễ bị
app khác copy nguyên lỗi.Round-27 audit follow-through — the 2 MAJORs the round-26 audit deferred are now fixed, plus the example app's ad-surface coverage gap it and agy inde
Round-27 audit follow-through — the 2 MAJORs the round-26 audit deferred are
now fixed, plus the example app's ad-surface coverage gap it and agy
independently flagged is closed:
RedeemedKeyLedger.markRedeemed()
read-modify-wrote the iOS Keychain with no serialization — two
near-simultaneous signed-VIP-key redemptions could both read the same
pre-write snapshot, then race to write, silently dropping one kid from
the durable one-time-use ledger. Now chains every write onto the previous
one (same idiom as AdEventLog._persistChain). Mutation-verified: new
test in test/redeemed_key_ledger_test.dart fires two concurrent
redemptions against a mock storage that snapshots its pre-delay state, and
asserts both kids land (revert → red, drops one kid; fix → green).AdMobAdapter's onFailed branch
for all 4 fullscreen ad types (app open, interstitial, rewarded, rewarded
interstitial) had no _discardIfDisposed-equivalent guard, unlike
onLoaded. A load failure delivered after dispose() still mutated slot
state and emitted through eventSink. Added the same _fullscreenDisposed
check to all 4, and dispose() now also nulls eventSink last as a
second line of defense. Mutation-verified: new test group in
test/admob_adapter_test.dart (one case per ad type) using a bridge that
can defer its onFailed callback past dispose().showRewardedInterstitialAd() had zero example-app coverage at
any level despite being a fully supported, README-documented ad surface —
found independently by both agy's round-27 audit and a direct grep
(0 matches for RewardedInterstitial anywhere in example/lib/).
Added RewardedInterstitialDemoPage (home-list tile, same pattern as the
other demos), a widget test (example/test/rewarded_interstitial_demo_page_test.dart),
and an on-device integration test
(example/integration_test/rewarded_interstitial_ad_test.dart) verified
passing on an Android emulator.example/lib/main.dart — merged the 18 files T117 (2.7.0)
split it into back into one file. Reason: pub.dev's "Example" tab renders
only the example app's entry-point .dart file, not files it
imports/exports, so post-T117 a pub.dev visitor evaluating the package
before installing it only saw a ~90-line stub of import/export statements
instead of any of the 18 real demos — confirmed by fetching the live
pub.dev Example tab directly. The T117 split remains the right call for
day-to-day editing in isolation; kept as one file anyway because pub.dev
presentation was judged more important here. No behavior change — verified
by flutter analyze/flutter test (both packages) passing unchanged
before/after the merge.Fix (T102, finally closed after 3 rounds): AdManager.destroy() now awaits the event log's flush before nulling it, closing a destroy()→initialize() ra
AdManager.destroy() now
awaits the event log's flush before nulling it, closing a
destroy()→initialize() race that could silently lose queued compliance
events. The fix itself was correct on the first attempt; what took 3
rounds was a flutter test hang the fix exposed — root cause was a test
bug (ad_manager_core_test.dart's remote-safety-provider timeout test
mixed fakeAsync with real platform-channel work, leaving an orphaned
tail running in real wall-clock time after the test's virtual zone
closed; unawaited(...) used to hide it, await exposed it), not a
production bug. Fixed the test to use real time instead of fakeAsync for
that scenario. Mutation-verified with a new AdManager-level test
(test/destroy_awaits_event_log_flush_test.dart).Round-23 audit: a full pass over the SDK, the example app, every doc in the package and the live pub.dev listing, against the seven production require
Round-23 audit: a full pass over the SDK, the example app, every doc in the
package and the live pub.dev listing, against the seven production
requirements. Three independent reviewers plus a line-by-line pass of my own,
then a second review round on the changes themselves; every finding was
re-verified against the source before being accepted (several were downgraded
or refuted). Consolidated verdict in
doc/audit/audit_round23_consolidated.md.
Nothing here changes a signature, so this compiles as a drop-in upgrade. Two values that a host can read now mean something different, which is why this is a minor bump and not a patch:
RewardResult.shown now means "the native SDK confirmed the ad reached the
screen", and defaults to false. It used to default to true on every
path, including the ones where no ad was ever displayed. If your app reads
the shown argument of showRewardedInterstitialAd(onDone: (shown, earned)),
re-check what you do with it: it is now the display signal, not a
"the show attempt happened" signal, and it is true for a real display the
user closed before the reward point.
AdShowEvent.success for rewarded and rewardedInterstitial now reports
the DISPLAY, not the reward. It used to carry earned. If you were
counting rewards off the event stream, count AdRewardEvent instead — that
is what it is for, and it is unchanged. AdShowEvent.success for banner,
interstitial and app-open is unchanged.
showRewardedInterstitialAd() now shows a disclosure screen before the ad.
Google's policy for the format requires it: the user must be told an ad is
coming and what the reward is, and be given a way out. AdScreenState
renders one by default — pass showDisclosure: false only if your app
already presents its own, and override disclosureTitle /
disclosureButtonLabel to localise it. Declining costs nothing: no ad, no
impression, no budget spent.
AdRevenueEvent.placement now reports where the ad was actually shown.
Before this release every revenue event arrived as
AdPlacement.unspecified, and App Open always claimed AdPlacement.splash
even on a resume. If you were grouping revenue by placement, the buckets
change shape — they start being correct. Banner, MREC and native still report
unspecified: nothing "shows" them, so there is no placement to take.
A wrong device clock could permanently ERASE a paid VIP grant. The SDK keeps a high-water mark of the furthest instant the clock has ever read, so that winding the date backwards cannot resurrect an expired grant. If that mark ever got poisoned — a phone with a flat battery boots years in the future, the user opens the app once, NTP corrects it later — the expiry sweep compared every VIP row against the poisoned mark, decided they were all over, and deleted them from disk. Unrecoverable: there is no backend, and the key id is already burned in the one-time-use ledger, so re-entering the key the customer paid for answered "already used". A row is now deleted only once the clamped clock and the raw device clock both say its window has ended — a window that has not STARTED yet (a grant taken while the clock was running ahead) is kept too, which matters on iOS where the VIP row is Keychain-backed and survives a reinstall while the clock mark does not. A poisoned mark can still suppress an entitlement; it can no longer destroy one.
One tap on "watch an ad" laundered a revoked key's window past the
revocation list. VIP grants stack globally by design, so redeeming a signed
30-day key and then watching one rewarded ad for "+1 day" moved the whole 30
days into the WATCH_AD entry — where the revocation clamp, which matches on
SIGNED_<kid>, could no longer reach it. Publishing a CRL for a leaked,
refunded or resold key did nothing. Stacked grants now carry, transitively,
what they absorbed, and the clamp matches on that too.
A cached revocation list verified itself, and a future-dated one could
switch revocation off forever. At startup the cached CRL was verified
against a public key stored beside it in the same plaintext record — self-
attesting. Anyone able to write app preferences could mint their own key
pair, sign an empty CRL dated far in the future, write both, and permanently
wedge the "only accept a newer list" rule against every CRL the publisher
will ever issue. The cache's issuedAt is now latched only once the host's
own key has confirmed it. The revoked set from an untrusted cache is still
applied — it can only ever narrow a grant.
A child-directed flag set during init never reached AppLovin MAX. MAX
reads the flag once, at native SDK init, and that init is awaited for up to
20 seconds. A host that starts initialize() and presents its age gate at
the same time — the ordinary splash shape — could call
setConsent(AdConsent(isAgeRestrictedUser: true)) inside that window and
have it silently dropped: the adapter had already been told false, and
setConsent()'s own re-init branch could not run on a first init. MAX served
ads to a user the host had declared child-directed. Init now re-checks the
flag on the way out and discards the adapter if it changed.
App Open ads were drawn on top of live banners and MRECs. Google's App
Open guidance names this placement as prohibited. The resume path walked
straight into it: inline surfaces are made visible again on resume, and only
then does the App Open decide to present. Banners and MRECs are now blanked
for the duration of the fullscreen ad and restored on dismiss (and after a
failure). A surface hidden for another reason — route-paused, backgrounded —
stays hidden. Nothing to call; a custom adapter that does not implement the
new InlineAdVisibility capability keeps the old behaviour.
The monetization arbitrator priced every ad format out of one pool. A
content feed emitting cheap banner impressions dragged the trailing average
below the rewarded threshold, so the next rewarded opportunity — worth many
times a banner — was vetoed in favour of a VIP nudge, and the emitted
ArbitratorNudgeEvent quoted an eCPM belonging to a different format. The
same bug compared non-USD revenue against a threshold documented in dollars.
Each format is now priced from its own history, in its own currency.
A rewarded interstitial that was displayed but dismissed early did not
consume an impression. The count sat inside if (result.earned), so
repeating the pattern handed out materially more fullscreen inventory than
the anti-invalid-traffic caps allow — the publisher's AdMob account carries
that risk, not the SDK's. It now counts display, like every other fullscreen
format, and the matching AdShowEvent no longer reports success: false for
an ad that was on screen.
An iOS Keychain timeout consumed the 1-day trial the user never got. The first-install guard reads the Keychain to decide whether the grace has already been given. If that read never answered, the SDK still marked the grace as applied — a one-way flag — so the trial was burned without ever being granted. The mark now happens only when the guard actually answers; a timeout simply tries again next launch.
A whitelisted test device lost VIP after ~90 days and could not get it
back. AdConfig.vipDeviceGaids grants a long window that the stacking cap
clamps to ~90 days, then set a one-way "already applied" flag — so when the
clamped window ran out the device silently went back to seeing ads, with no
way to re-grant short of clearing app data. The grant is re-applied when the
whitelist still matches and no VIP is active.
Banners stayed blank at the exact moment VIP expired. Gaining VIP hid them immediately; losing it did not bring them back until something else happened to rebuild the widget. The VIP transition now signals the banner to reload.
A CCPA / US-state sale opt-out written by a CMP now actually reaches both
ad providers. The SDK has parsed IABUSPrivacy_String since 2.3.0 and
reported it through AdManager().usPrivacyOptedOut, but AdConsent.doNotSell
was writable by the host and by nothing else — so a user who opted out through
a CMP still had AppLovin setDoNotSell(false) and AdMob
restricted_data_processing unset unless the host separately noticed the
string and called setConsent itself. The opt-out is now reconciled at SDK
init and on every app resume (so one made while the app was backgrounded lands
too), and applied to both providers. Tighten-only: a string that says the user
did NOT opt out, and the absence of any string, never clear a doNotSell the
host set deliberately. IABGPP_HDR_GppString is still deliberately not
decoded — see the new README section "CCPA / US state privacy".
A VIP reward earned while the SDK is re-initialising is no longer lost. The watch-ad-for-VIP flow held the VIP manager it read before showing the ad; a provider switch or re-init during the ad discarded that manager, so the grant was dropped while the screen still reported success. The grant now goes to whichever manager is live when the ad finishes.
An SDK teardown can no longer roll back the VIP revocation list. A revocation-list fetch still in flight when the SDK was destroyed used to write its (older) result over the newer list the re-initialised SDK had already cached, making a revoked key redeemable again on the next launch. The fetch is now discarded if the manager that started it has been torn down — including a teardown that lands while the fetched list is being applied to existing grants.
A VIP redeem interrupted by an SDK teardown no longer consumes the key. The one-time-use ledgers were written even when the entitlement itself was dropped (a discarded manager must not write over the live one's store), which left a paying customer with a burned key and no VIP window — durably on iOS, where the replay record survives a reinstall. The key is now marked used only once the grant has actually been persisted.
A refused native AdView destroy could arm a retry timer that outlived
destroy(). destroy() cancels the retry timers it can see, but a destroy
still in flight fails afterwards and used to schedule a fresh one, which then
called into a bridge whose listeners were already cleared. It now stops
retrying once the teardown has begun; the retry chain is unchanged on a live
adapter.
An AppLovin banner/MREC preload landing during destroy() aborted the rest
of the teardown. The AdView-destroy loops awaited the native bridge while
iterating a map that the in-flight preload then inserted into, throwing
Concurrent modification during iteration. The exception was swallowed one
layer up, so the host saw a successful teardown while MREC views were left
alive, pending loadAppOpen/interstitial/rewarded callbacks were never
answered, and the old adapter kept its config. Repeated destroy/re-init cycles
accumulated native views.
An AdMob ad delivered after destroy() leaked its native ad object. GMA
can hand over a fill at any time, including after teardown; the four
fullscreen load handlers stored that late ad into an adapter that had already
released everything it held, and since the next initialize() builds a fresh
adapter, nothing ever disposed it. Late fills are now released on arrival.
Banner/MREC/native were already covered by their per-key identity guard.
A valid VIP key was rejected as "invalid or expired" when redeemed in the
first second after app launch. The connectivity plugin's first snapshot
after process start can report offline on a device that is online (seen in 3
of 36 launches on a real phone), and the redeem gate trusted that single
read. It now polls for up to 2s and lets the first positive answer through.
A genuinely offline redemption also stops lying about the cause:
SignedVipRedeemResult.isOffline is set (the status stays
VipRedeemStatus.invalid, so no exhaustive switch in a host app breaks),
and the shipped VipRedeemScreen shows a new VipRedeemStrings.offlineMessage
("No internet connection. Connect and try again — your key is still valid.")
instead of the invalid-key message.
An ad load could start during destroy(), and its callback could crash the app.
The four loadX methods now refuse while a teardown is in flight, and an
AdSlot's state notifier drops (and counts) writes that arrive after it was
disposed. Before this, a native load callback landing after teardown wrote a
disposed ValueNotifier and threw A _SlotStateNotifier was used after being disposed — reachable by any app that called destroy() while an ad was
loading. Requests fired during a teardown are also pure waste: never shown,
but counted by the ad network as a request with no impression.
A VIP rewarded ad could play, and pay out, over an SDK being torn down.
The teardown check sat at the head of each show method, but the VIP
"watch an ad to extend your window" path then waits up to 15s for an
on-demand load, and the re-check after that wait did not know a destroy()
had started meanwhile. The teardown is now part of the shared fullscreen
busy gate, so every ad type and every post-wait re-check inherits it. The
public fullscreenBusy notifier is recomputed on both edges of a teardown.
AdManager().adapter returns null while a teardown is in flight.
The adapter's own show… methods are public and answer to none of the
safety layers in AdManager, so a host holding the adapter could drive the
native layer straight past consent, caps and the fullscreen mutex during a
teardown. Fetching it mid-teardown now yields nothing to call. (Banner /
MREC / native widgets read this getter and correctly stop building.)
A resume that started just before destroy() could still show an ad.
Detaching the lifecycle observer stops a new resume, not one whose 500ms ad
buffer was already running — its completion callback found the adapter still
live and showed an App Open ad on top of an SDK being torn down. No fullscreen
ad (App Open, interstitial, rewarded, rewarded interstitial) can now start
while a teardown is in flight; the attempt is reported as a
teardown_in_flight skip event instead.
One paused events subscriber could hang destroy() — and brick the SDK.
The teardown awaited the event-stream close with no bound. A host subscription
may legally be paused (route transition, backpressure), and a paused subscriber
buffers the done event, so the close never completed — while every later
initialize() parked behind the in-flight teardown and isInitialised still
answered true. The wait is now capped at 2s with a warning log.
An App Open ad could be shown on top of an SDK being torn down. The app
lifecycle observer was detached at the very end of destroy(), and the resume
fallback timer cancelled later still, both after the teardown's awaits — across
which the adapter and config are untouched, so every guard on the resume path
still passed. A user returning to the app mid-teardown could be shown an ad,
with the native call landing on an adapter about to be disposed. Both are now
disarmed before the teardown's first await.
The 5-minute ad-refill poll can no longer fire inside a teardown. The poll
guards itself on isInitialised, which is _config != null && _adapter != null
— and both fields stay non-null until well past the teardown's first await. So
a tick landing in that window passed every guard and refilled ads into an adapter
about to be disposed. The poll and the connectivity watch are now both stopped
before the first await, matching what re-initialize() already did.
A pending init retry can no longer bring the SDK back after destroy().
The retry timer was cancelled at the end of the teardown, so it stayed armed
across the event-stream close and the adapter dispose. A retry firing in that
window waited the teardown out and then built a whole new session — adapter,
timers, connectivity watch and ad requests — moments after the host's
await destroy() returned. The retry is now cancelled before the teardown's
first await.
Two destroy() calls at once no longer tear the SDK down twice. The
second caller waited for the first teardown and then ran a whole extra one:
every widget subscribed to initRevision rebuilt twice, and if the app had
already restarted the SDK in between, the redundant teardown disposed the
new session's adapter — ads silently dead for the rest of the process.
A cancelled init retry no longer strands the caller it was holding. When
native init fails the SDK arms a backed-off retry that owns the caller's
onComplete. Cancelling that retry — which both a fresh initialize() and
destroy() do — threw the callback away with the timer, so that caller was
never answered at all. A splash that tapped its own "Retry" button mid-backoff
therefore waited out its hard-cap timer even though the SDK had come up. The
callback is now handed to the replacing attempt (and hears its real result),
or answered false by the teardown.
A reported init failure no longer leaves the SDK claiming it is
initialised. If a step after the ad provider came up threw — applying
consent to the providers, or reading the stored IAB consent string — the host
was told initialisation failed while AdManager().isInitialised still
answered true, with a live native adapter and its listeners still attached.
An app that does not re-initialise on failure leaked that adapter for the rest
of the process, and it kept serving ads. The SDK now tears the adapter down
before reporting the failure, so the two answers agree — and it does so even
when the ad provider's own teardown throws, which used to abandon the state
reset half-way and bring the same contradiction back.
destroy() now really stops an initialize() that is still running.
Native SDK init can take up to 20 seconds, and an app that gave up and tore
the SDK down in the meantime used to have it come back to life afterwards:
the finishing attempt installed its ad provider, timers and connectivity
watch into the torn-down SDK and reported success, so isInitialised went
true again moments after the app had been told the SDK was gone. Such an
attempt now releases what it built and reports failure instead. Same for the
narrower window while consent is being applied to the providers.
A parked caller can no longer be stranded by another parked callback that
calls initialize() again, or by one that throws. A callback that re-enters
initialize() while the queue is being answered is handed the result being
delivered on the spot instead of parking behind it. The queue is also capped
at 32 waiting callers (the 33rd is told false at once rather than parked),
and a caller that parks during destroy()'s own teardown — a window that had
already drained the queue — is answered by the abandoned attempt rather than
waiting for a callback that would never fire.
A consent answer from a torn-down session can no longer open the live session's ad gate. The UMP consent flow is not awaited (it presents a native form and can take minutes), so its result could land after the app had torn the SDK down and initialised it again — and it was written to the ad gate regardless. A session that is deliberately holding ads back until its own consent flow answers, or that runs a stricter config (an under-age-tagged one, say), could therefore be overruled by an answer gathered for a session that no longer exists. UMP results and the fail-open error path are now bound to the session that started them, matching the privacy-options form.
destroy() no longer takes a session that started during its teardown apart
with it. Tearing the SDK down involves waiting on the ad provider, and an
initialize() arriving in that window used to be built and then dismantled by
the rest of the teardown — most visibly it lost the app-lifecycle observer, so
App Open on resume and the ad pause/resume hooks silently stopped working for
the rest of the process. initialize() now waits for an in-flight destroy()
to finish, so a destroy-then-initialise pair does what the app asked, in the
order it asked.
An abandoned initialize() can no longer damage the session that replaced
it. Two failure paths did not know they had been superseded. One: an attempt
whose provider init came back false after destroy() still armed its
5-second retry timer, so the torn-down SDK re-initialised itself, and still
fired the init-completion event with false — and because the event bus
replays its most recent event, a splash that subscribed late was told init had
failed even when a later attempt had succeeded. Two: an attempt that threw
after another attempt had already won decided what to tear down by reading the
shared state, so it disposed the winner's live provider and flipped
isInitialised back to false for a session that never failed. Both now bow
out and report only to their own caller. Same check added after the VIP load
and the consent bootstrap, so a destroy() during either can no longer leave
a torn-down SDK holding live VIP or consent state.
SafeLogger.critical can no longer be hidden by logTagFilter. It
already ignored AdLogLevel.none; it now ignores the tag filter too. The two
events that use it — a release build forcing dryRun back off, and a config
that can never gather consent — mean your own configuration is wrong, and the
consent one is the difference between showing an EEA/UK user a consent form
and not. An app filtering logs down to its own tags used to lose it silently.
Ordinary d()/w()/e() still respect the filter exactly as before.
A second initialize() call made while the first is still running is no
longer answered with silence. It used to log "skipping duplicate" and
return without ever calling that caller's onComplete or firing an event, so
an app whose splash awaited the second call waited forever. Such callers are
now parked and told the real result of the in-flight attempt (and told
false if destroy() happens first). They still never start a second ad
provider.
The iOS "you called initialize() before requestAtt()" warning now
actually fires under test, which is how it was found to be untestable in the
first place: it asked dart:io whether the platform is iOS, and now asks
Flutter. Same answer on a real device.
A host onLog callback that throws can no longer strand the SDK. Every
log now goes through one guarded emitter, so an exception out of your own log
sink is caught and reported instead of unwinding whatever the SDK was doing —
which, for logs written from inside a teardown's catch, meant the state
reset stopped half-way.
An app that calls initialize() again from its own failure callback is no
longer ignored. The in-progress guard was still held while onComplete(false)
ran, so a host retrying with a fallback configuration from inside that callback
was dropped silently: it had been told initialisation failed and its own
recovery then did nothing.
A developer warning no longer silently disables ad loading for the whole
session (debug/profile builds). The two asserts described below ran ahead
of the App Open + banner preload, the ad retry timer and the connectivity
watch. In a non-release build the assert threw and all of those were skipped,
so ads simply never loaded — a symptom that looks nothing like the warning
that caused it. Both asserts are now gone: an assert inside a try that
catches everything can never crash anything, it only produced a stack trace
the SDK then logged as if init itself had failed. The warnings are now
SafeLogger.critical instead, which reaches your own onLog sink and is not
silenced by AdLogLevel.none. The release-mode consent block, and the rule
that no ad is requested while no consent flow is configured, are unchanged.
A splash screen no longer hangs waiting for an init-completion event that
never comes (debug/profile builds). The SDK's two developer warnings — no
consent flow configured, and requestAtt() never called on iOS — are
asserts, and they ran before initialize() told the host it had finished.
In any non-release build the assert threw, the init body swallowed it, and the
host callback plus the completion event were skipped even though native init
had actually succeeded: a splash built on the documented contract (subscribe
to the init event) sat there until its own hard-cap timer rescued it. The
warnings now fire after completion is reported, and a host onComplete that
throws can no longer swallow the event either.
A failure after native init no longer re-initialises the SDK every 5
seconds forever. The auto-retry budget is reset once the adapter comes up,
so anything throwing after that point — including a host onComplete
callback that throws — got an unbounded retry: rebuild the adapter, throw
again, reset the budget, retry again. Such a failure is now terminal and
reported once; a genuinely failed native init keeps the bounded retry it
always had.
Ad impressions are now counted from whether the ad reached the screen, not
from how the show ended. Three separate symptoms turned out to be one
mistake: a rewarded ad the user closed after two seconds, a
rewarded-interstitial that never displayed, and an app-open ad resolved by
the 90-second hard cap were all mis-accounted. A real display that earned no
reward counted as nothing (so the daily/hourly caps that protect the AdMob
account stopped seeing those impressions), while a show that never reached
the screen was reported to the host as shown: true. There is now one
authoritative signal, AdSlot.displayConfirmed, set when the native SDK
confirms the ad is on screen, and both adapters plus AdManager read it.
RewardResult.shown now defaults to false and means "the native SDK
confirmed this ad reached the screen" — independent of earned. It used
to default true, so every never-displayed path reported a display.
Hosts reading onDone(shown, earned) get the truth now; a host that treated
shown as "the user watched something" should re-check that assumption.
AdMob's app-open slot never called markDisplayed() — the only
fullscreen slot that didn't, which is why its hard-cap path could not tell a
real display from a lost callback.
A device clock parked in the future can no longer mint a permanent VIP (MJ9, carried as a documented limitation for three rounds). Setting the clock a year forward, redeeming any grant, then correcting the clock used to leave an entry that never expired, because the anti-rollback high-water mark was the only clock consulted and it agreed the entry was mid-window. An entry now additionally has to have started according to the raw device clock, while expiry keeps using the mark — so the 30-day-rollback defence is unchanged. A suppressed entry is never deleted, only suppressed, so a customer whose device clock was genuinely fast when they paid keeps their grant.
VIP grants are persisted as UTC. They were written as local ISO-8601
with no timezone marker, so the same text read back on a device that had
changed zone (a flight west, a region's UTC-offset change) resolved to a
different instant — up to a day earlier. VipManager then read the grant as
expired and _purgeExpired() deleted it, with no server to restore from.
Entries are stamped UTC on write and converted back to local on read, so
every existing consumer (display, countdowns, difference) is unchanged.
Entries written by 2.3.4 and earlier still decode.
The VIP grace nudge no longer fires at grant time. Its default threshold (24h) is exactly the default first-install trial length (24h), so a brand-new user saw "your VIP is about to run out" on their first launch. The threshold is now capped at half the granted window, in both the check and the timer that schedules it.
A revoked VIP key id (kid) is now matched case-insensitively at
redemption. Clamping an already-granted window matched through
normaliseKey (upper-cased) while the redemption gate compared exact case,
so a CRL whose kid case differed from the key's clamped the old grant but
still handed out a fresh one for the same revoked key. Both mint tools
(tool/vip_mint.dart, tool/vip_crl_mint.dart) now upper-case kids, and
keys minted before that still match.
compliance_signing.dart returned a Future without awaiting it inside a
try, so a corrupt stored seed threw past the fallback instead of minting
a fresh key pair. (Also the 20 pana points that warning was costing.)
pubspec.yaml pointed at a repository that 404s (FlutterBase2025 →
FlutterBase2026), which broke the source links and the License link on the
live pub.dev page.
buildAdmobNativeView(key) sample now compiles, the logLevel
default is documented as build-mode-gated (debug .verbose, release
.warning), the Step 5 splash sample no longer leaks its SimpleEventBus
listener and now calls requestAtt() before initialize(), the AdConfig
configuration reference lists the four params it was missing
(maxVipStackDuration, onPrivacyPolicyTap, disableAppLovinCmpFlow,
enableCrashGuard), and the quick-start floor is current.doc/AD_PROMPT_FLUTTER.MD: the flagship splash snippet compiles again
(adMob: → admob:).CLAUDE.md,
doc/README_TESTING.md, doc/feature.md and doc/architecture.md.flutter_secure_storage widened to >=10.0.0 <12.0.0. This package still
resolves 10.x (11 needs win32 ^6, which package_info_plus 9 blocks, and
package_info_plus 10 needs Flutter >= 3.38.1) — the wide bound lets a
consuming app that is already there pull 11.applovin_admob_sdk 2.3.4
applovin_admob_sdk 2.3.4
Nine further QC rounds (13-22) on the consent path alone, all of them driven by on-device verification rather than by the unit suite. Both final reviewers scored the result 10/10 with zero findings. Verified on a Samsung A50 and a Samsung A11 (the consent resume backstop, 5/5 on each).
A rewarded ad can now be watched more than once per session (AdMob).
Found by an on-device smoke test with real AdMob test ads, not by the suite:
AdMob delivers onUserEarnedReward before onAdDismissed, and the reload
hung off the reward callback — so it ran while the spent ad was still cached
and did nothing, and no second reload ever came. After one completed rewarded
ad the slot stayed empty for the rest of the session, so the next "watch an
ad for a reward" tap silently did nothing until the app was restarted. The
refill now happens on dismissal, where the spent ad is already cleared, and
still goes through AdManager's own gate (VIP / daily cap / consent / network).
Rewarded Interstitial had the identical shape and is fixed with it. The
AppLovin adapter was never affected — it reloads inside its own
onAdHiddenCallback.
Withdrawing consent through the Privacy Options form now applies even when
the form is open for a long time. Found by on-device verification (Pixel 7
Pro, EEA debug geography — see doc/audit/audit_round13_device.md), not by a
test: the flow gave up waiting after 20 seconds, read the consent status
while the native form was still on screen, and never read it again. A user
who spent longer than that in the form and then withdrew consent kept getting
personalised ads for the rest of the session, with their own withdrawal on
record. The wait is now the same human-reading bound as the initial consent
form (kFormDismissTimeout), a late dismiss re-reads and re-applies the real
choice, and every app resume re-applies consent when the device's IAB TCF
state disagrees with what the providers were told — so a withdrawal cannot be
lost even if the dismiss callback never arrives at all.
A consent withdrawal now applies with no network at all. The re-apply that
carries a withdrawal used to re-read the device's TCF state a second time, and
used to wait on UMP unbounded. Offline, or during a UMP outage, that second
read could throw or come back empty — and "no TCF data" means "assume
allowed", so a re-apply that was meant to carry a refusal came back out of the
pipeline as a grant, leaving both providers personalised under a user's
refusal. The withdrawal is now settled by the refusal the caller already read:
the UMP read is bounded to 2 s and optional, the ad gate is closed for the
duration of the write and reopened by an owed-recovery debt that a reconnect
also pays, and a newer host setConsent landing mid-check always wins.
applovin_admob_sdk 2.3.3
applovin_admob_sdk 2.3.3
Six more independent QC rounds (7-12) over the whole package, against the seven
product requirements. Every claim below is backed by a test verified red against
its own reverted fix — nothing else. Full write-ups in doc/audit/.
obtained is no longer read as consent to personalised ads. It only
means the user answered the form; the actual TCF purposes are now parsed
before anything personalised is requested.bypassSafety: true (the splash App Open ad) no longer skips the
invalid-traffic pause.showing forever; both providers now
release it.loading forever (watchdog added) and
could resurrect a key disposed mid-preload, leaking the native ad view.Each item below is backed by a test that was verified red against its own reverted fix, and nothing else.
now against the
wall-clock load stamp with no lower bound, so a backwards clock change
(manual, or an NTP correction) put the stamp in the future, made the computed
age negative, and kept the ad inside its validity window forever. Both the
reuse-on-load and the refuse-to-show-a-stale-ad guards stopped working. A
negative age now counts as stale.canShowInterstitial() / canShowRewardedAd() could report true for an
ad that would not be shown. Both only asked whether the slot was ready, so
a cached AdMob ad that aged past its 1h content validity while the host was
polling still reported showable — and the show call then discarded it. A host
gating a button on these got a button that did nothing.google_mobile_ads wrapper cleared its full-screen content callback on
dispose but left the paid-event listener wired, so a paid event arriving after
disposal still emitted revenue through the old event sink.destroyWidgetAdView succeed once the native view has finished detaching
slept on an untracked timer, so a chain started by the last widget unmount
before teardown kept calling into the bridge for up to ~1.7s after
dispose() had already cleared every native listener. The retries are now
cancelled by dispose().dispose() walked only the slot maps, but the per-key listenable
bundles (and AppLovin's per-key ad-view-id notifiers) are created
independently of the slot, so any key that only ever had those kept its
ValueNotifiers alive for good.Two independent QC passes over the round-5 work found these. As above, each claim is backed by a test verified red against its own reverted fix.
Two independent QC passes over the round-5 work found these. As above, each claim is backed by a test verified red against its own reverted fix.
State. A tombstone
added to stop late callbacks from resurrecting a dead key was permanent, so
the reload got a disposed slot and disposed ValueNotifiers instead: the ad
never returned for the rest of that widget's life, and callbacks wrote to
disposed notifiers. The tombstone is now lifted when a live widget re-loads
the key, while a late callback for a key nobody revived still gets the shared
disposed sentinel — so the leak the tombstone exists to prevent still cannot
happen. AdMob was unaffected (it guards by slot identity, not by key).SimpleEventBus.fire guarded every listener so one failure couldn't block
the others, but the later-added replay in listen did not — so a subscriber
that threw escaped straight out of listen() into its caller. Per the
integration contract that caller is a listen() line in the consuming app's
splash. Guarded, matching fire.A second independent reviewer went over the round-5 diff after it shipped (same discipline as the first: every claim below is backed by a test that fails against the reverted fix, not taken on trust).
_appOpenAd fresh when the timer fired and checked that value against
itself, which is always true and guards nothing. Not reachable through any
load/show path today (other guards happen to cover it), but a maintenance
hazard for the next change here — fixed to capture the specific ad at arm
time, same as every other call site in the file already does.Same review found a large fraction of round-5's diff was revertible in bulk with the suite staying green — the fix existed but nothing exercised it. Each one below now has a test verified red against its own reverted fix:
adapter-orphan-on-failed-init disposal, the new banner/MREC/native load
watchdog (plus its dead-cache cleanup and a dispose-during-await race in the
banner path), the AppLovin COPPA re-init reachability fix, the UMP mutex's
240 s self-heal timeout, AdLoadingDialog.show()'s flag-ordering fix, and
the banner widget's consent-withdrawal listener (the fix already existed;
only a counter was asserted, not the widget behaviour it drives). Two related
fixes — the identical one for MREC/native widgets, and a rewarded-dialog
ownership check that turned out to be unreachable through any current call
path — remain unverified; see doc/audit/audit_claude.md's handover section.
Round-5 audit, commits 2–3: the rest of the consent surface, then the fullscreen-lifecycle failures that could kill a surface for a whole session.
An independent reviewer was pointed at the round-5 diff before it shipped. It found that three of the flagship fixes did not work on their own main path, and that one of them made a transient hang permanent. All of it was confirmed by reading the code, not taken on trust — and one of the reviewer's own recommendations was rejected after reading the test that documents the opposite invariant (see below).
_consent against the incoming consent inside the listener, but
setConsent() assigns _consent first and only then calls
ConsentManager.set(), whose ValueNotifier notifies synchronously — so the
listener always saw the new value on both sides and the guard could never
fire. Every withdrawal route (showPrivacyOptions(), requestUmpConsent(),
a host's own setConsent) goes through exactly that sequence, so personalised
fullscreen ads already in the cache were still shown. Now compares against
what was last actually applied to the adapter, which no assignment order can
break. Regression test included, and verified to fail against the old code.initRevision cannot rebuild
a banner that is already showing: each widget's listener only re-inits when it
has no ad, and withdrawing personalisation does not close the canRequestAds
gate that would clear that state. A dedicated personalisationRevision
signal now tells banner/MREC/native to drop their live instance and reload.setConsent()
returned early when the SDK was not initialised, above the block that
rebuilds the adapter — but the child-directed abort is exactly what leaves it
uninitialised, so the host's later "not a child after all" call returned
before the recovery ran. The block now runs first, and the last known-good
config survives adapter teardown so there is something to rebuild from.loadBanner early-returns while the key is still in
_bannerAdsByKey, so the cached-but-dead ad blocked every later load for
that widget instance; only a remount (which produces a new key) appeared to
recover. The watchdog now drops the ad object as onAdFailedToLoad does.setConsent → _persist() → updateRequestConfiguration are
all unbounded, so a single wedged channel meant every later
requestUmpConsent() joined a future that could never complete: the gate
would stay shut with no self-heal, strictly worse than the lockout this round
set out to fix. Now capped at 240 s, and the lock is only released by the
call that owns it.Future.timeout does not close.
The backstop now recognises that state and rechecks Google's already-cached
consent decision (no form, no network call) instead of presenting another
one — see "Fixed — independent review, round 2" below for why the first cut
of this (standing down entirely) was itself a regression.PackageInfo (an unbounded channel it was skipping), AdLoadingDialog.show()
got the same flag-ordering fix its sibling already had, the rewarded path no
longer claims ownership of a dialog it did not open, and the App Open hard cap
clears the field by identity like the callbacks do.AdConfig.autoShowConsentDialog now documents that it has no effect with
the default autoRequestUmpConsent: true. The behaviour was introduced above
on purpose — the built-in dialog is not a certified CMP and produces no TCF
string — but shipping a default-true flag that silently does nothing, with no
word in its own doc, is its own kind of trap.shared_preferences_android is declared without an upper bound. A <3.0.0
cap would become a new pinning wall for every consuming app the moment
shared_preferences requires 3.x. A compile-time canary test guards the
platform API this package leans on instead.appOpenSlot still reported ready, so showAppOpen returned
false against a null ad forever, and _retryRefillAds only refills
idle/cooldown slots so nothing repaired it. Callbacks now clear the field
only while it still points at their own ad, and the watchdog disposes the ad
it gives up on instead of just forgetting it.await ad.show(...). If the
platform call itself never resolved — the exact hang the watchdog exists for
— it was never armed at all. Armed before the await now.AdLoadingDialog.showAdBuffer could block every fullscreen ad for the
session. It set _isShowing = true before Navigator.of() and the route
push, neither guarded, and every caller is fire-and-forget: a throw left the
flag stuck true, so _fullscreenBusyReason reported "ad loading buffer
showing" forever, and onComplete never ran — hanging a splash that awaited
it. The flag is now raised only once the route exists, and a failure still
calls onComplete as the docstring promises.AdLoadingDialog.dismiss() could strand a later dialog with no way to
close it. Unlike resetState() it did not bump the generation, so a
sleeping showAdBuffer timer woke up, believed it was still current, and
cleared state belonging to a NEWER dialog — which then had
barrierDismissible: false, PopScope(canPop: false) and a dismiss()
that early-returns: a frozen UI. The rewarded on-demand path also stopped
dismissing dialogs it never opened.initialize() left the adapter alive. AppLovin wires its four
native listeners before awaiting SDK init, so on the 20 s timeout branch the
native side could still come up and keep calling into slots this manager had
abandoned — up to four orphans across the retry chain, each holding ~15 live
ValueNotifiers.beginLoad() and the widget showing its
shimmer placeholder with hasError false. Now bounded at 30 s, which lands
the slot in cooldown — a state a remount retries.showing, since
those formats deliberately have no show-watchdog; if the callback that would
have advanced the slot was the thing that crashed, it stayed stuck.show* catch blocks dropped the ad without disposing it, leaking
the native object. Reachable: gma_bridge awaits setServerSideOptions()
before showing, and a platform call can throw.disableAppLovinCmpFlow: false as proof that a consent flow existed, but
that flag is only ever read by AppLovinAdapter.initialize, so on AdMob it
means nothing. The combination provider: admob +
autoRequestUmpConsent: false + disableAppLovinCmpFlow: false — a config
the SDK accepts silently — produced no warning and left canRequestAds at
its default true: EEA/UK users served ads with no consent flow at all.
AppLovin's CMP now only counts when AppLovin is the active provider.AppLovinMAX.initialize, not
before. On an ordinary cold start (host never called setConsent, so
nothing was buffered) the post-init applyToProviders was the first time
AppLovin heard about consent — MAX documents these as init-time settings.
AdProviderAdapter.initialize now takes the consent state so each adapter
can apply it in the order its own SDK requires.tagForUnderAgeOfConsent never reached AdMob.
AdConfig.umpTagForUnderAgeOfConsent only fed UMP's consent form, so an app
declaring an under-age audience got the right form and then sent every ad
request out with no under-age signal. Now set on RequestConfiguration —
only ever as yes; absent an explicit declaration it stays unspecified
rather than asserting no.AdManager._consent was a second source of truth that
ConsentManager.set()/reset() never updated, so anything rebuilding an
AdConsent from it (a UMP backstop retry, showPrivacyOptions()) wrote
doNotSell: false back to disk, to AdMob's rdp extra and to AppLovin's
setDoNotSell. The two are now kept in sync at the single point every
consent change already flows through.applyConsent only affects future requests, so the personalised
app-open/interstitial/rewarded ads already in the cache were still shown and
banners kept refreshing; ad age was the only thing that could discard them.
New AdProviderAdapter.discardCachedFullscreenAds() runs on a
true → false transition, alongside an initRevision bump for inline ads.
Never touches an ad that is on screen.isAgeRestrictedUser: true
correctly hard-stops ad requests (MAX 4.x has no runtime API for it), but
correcting the flag back to false left every AppLovin surface dead for the
rest of the process with nothing in the log to say why. The adapter is now
re-initialised when the flag changes in either direction.tcfConsentString always returned null on real devices. It read
through the legacy SharedPreferences API, which on Android reads its own
private file (UMP writes to the app's default store) and on iOS prefixes
every key with flutter. (UMP writes none). Its unit test passed against
setMockInitialValues, so the API looked wired for four audit rounds while
answering null to every caller. Now reads the platform's own store —
verified on Android hardware, returning a real TCF v2 string.initialize(). An unreadable status fell through to "do not defer",
which then called AdvertisingId.id(true) — and that true asks the plugin
to raise the ATT prompt, outside the host's control. Unknown is now treated
like notDetermined, as is a notDetermined that survives a timed-out
requestAtt(). The status read itself is now bounded at 5 s, matching what
the self-check already did to the same call._startConnectivityWatch() was called exactly once and is
best-effort, so a plugin init that threw left isConnected pinned to its
optimistic seed: every offline load just failed into backoff and
refill-on-reconnect never happened. The poll tick now re-attempts it._attRequested is reset by destroy() like the other guard flags.AdManager.usPrivacyOptedOut and AdManager.gppConsentString — the IAB US
Privacy and GPP signals a CMP leaves in platform storage.
usPrivacyOptedOut returns null when no string exists, deliberately
distinct from false: AdConsent.doNotSell is host-set only, so
exportComplianceReport reported doNotSell: false for a California user
who had opted out through a CMP. GPP is exposed raw rather than decoded —
mis-parsing a privacy signal is worse than not parsing one.debugFormDismissTimeoutOverride — lets an on-device harness cap the
consent-form wait, since no harness can tap a native dialog and would
otherwise sit out the full 180 s.AdProviderAdapter gains consent: on initialize and a new
discardCachedFullscreenAds(); GmaBridge.updateRequestConfiguration now
takes the COPPA/TFUA tags (RequestConfiguration replaces rather than merges,
so passing only test-device ids wiped them). Breaking only for a custom
adapter or bridge implementation.shared_preferences_android directly. It is already in every
Android build as the implementation of shared_preferences; the direct
dependency exists solely because SharedPreferencesAsyncAndroidOptions —
the only way to point a read at the app's default preference file, where UMP
writes — is not re-exported by shared_preferences.Consent-path hotfix. Every item below was found by the round-5 audit and the first two were reproduced on real hardware (Pixel 7 Pro, debugGeography:
Consent-path hotfix. Every item below was found by the round-5 audit and the
first two were reproduced on real hardware (Pixel 7 Pro, debugGeography: debugGeographyEea, real UMP forms) before and after the fix.
isConsentFormAvailable(), which reports whether a form exists, not
whether consent is required — and a form stays available after consent,
because that is what backs the Privacy Options entry point. Confirmed on a
real device (Pixel 7 Pro, debugGeography: debugGeographyEea): a cold
restart with consent already granted logged status=obtained formShown=true and put the form back on screen. Now uses Google's own
ConsentForm.loadAndShowConsentFormIfRequired behind a
status == required guard, so an already-answered user is never asked
again and the common (non-EEA) case skips the platform call entirely._umpAttemptFailed was result.error != null
alone, but UMP returns error == null with canRequestAds == false
whenever it resolves from cache without being able to serve a form — the
ordinary "flaky network on first launch in the EEA" case. Both retry paths
gate on that flag, so the gate stayed closed for the rest of the process:
zero ads, no self-heal short of an app restart, even once the network
came back. It now also covers an inconclusive result and a still-closed
gate.requestConsentInfoUpdate, and a cap
still exists so an unattended simulator cannot hang the flow forever.tagForUnderAgeOfConsent when they did. Concurrent
callers now join the in-flight request instead of presenting a second
form and racing each other's writes to the gate; retries replay the
params of the original call, so a child-directed app no longer collects
consent through the wrong form (which would not have been valid for an
under-age audience) and an EEA-debug run stays reproducible.example: --dart-define=UMP_EEA_DEBUG=true --dart-define=UMP_TEST_ID=<hash>
drives the real EEA consent path on a test device. Without it a tester
outside the EEA can never reach UMP's required branch, so every EEA-only
code path stays unexercised — that blind spot is what let the two consent
bugs above ship. UMP_TEST_ID is the hashed device id UMP prints to the
log on first run.release: applovin_admob_sdk 2.3.0
release: applovin_admob_sdk 2.3.0
AdMobConfig.effectiveTestDeviceIds / kQaTestDeviceHashes — this team's
own QA device fleet's AdMob test-device hashes are now always merged into
RequestConfiguration.testDeviceIds on every initialize()/consent
re-apply, regardless of what a host app configures in testDeviceIds.
Keeps manual QA on real hardware from ever counting as real
impressions/clicks (and the invalid-activity rate-limit risk that comes
with it), without the host app having to know or maintain the list.initialize()'s autoRequestUmpConsent branch could fail
open on a real consent-fetch error, not just an unwired UMP channel.
Any exception used to fail the gate open; now only MissingPluginException
(channel genuinely not wired) fails open — every other exception (a real
UMP fetch failure) fails closed, so a network hiccup can no longer
silently ship ads with no verified consent decision.destroy()/_resetGuardState() left the previous session's
device GAID behind. A stale GAID surviving past teardown into the next
initialize() is a privacy leak; it's now cleared as part of guard-state
reset.canRequestAdsListenable gate mid-session
never triggered a frame in BannerAdWidget/MrecAdWidget/
NativeAdWidget. _onCanRequestAdsChanged()'s reload path relied on
addPostFrameCallback, which does not itself schedule a frame — the
reload silently no-opped until some unrelated frame happened to fire.
Fixed by calling WidgetsBinding.instance.scheduleFrame() alongside it.MonetizationArbitrator's with-estimator branch could veto
ads at zero eCPM. The no-estimator branch already guarded on
ecpm > 0; the with-estimator branch was missing the same guard, so a
session with no revenue events yet (ecpm == 0) could still have ads
vetoed whenever the host's likelihood estimator reported > 0.5. Both
branches now require ecpm > 0 before vetoing.AdManager().currentDeviceGaid and AdManager().adMobTestDeviceHashHint() — the latter returns instructions (device's current GAID included, clearly lab
AdManager().currentDeviceGaid and AdManager().adMobTestDeviceHashHint() —
the latter returns instructions (device's current GAID included, clearly
labeled) for finding this device's AdMob test-device hash via logcat tag
Ads, since Google has no public API/formula for that hash. Intended for
a host app's own debug UI; distinct from the GAID, which is not valid for
AdMob's RequestConfiguration.setTestDeviceIds().Docs-only. No code changes. Prompted by an independent multi-agent audit (Claude/Codex/Gemini, doc/audit/audit_*.md) flagging that the pubspec descrip
Docs-only. No code changes. Prompted by an independent multi-agent audit
(Claude/Codex/Gemini, doc/audit/audit_*.md) flagging that the pubspec
description overclaimed "Offline VIP redeem".
description — "Offline VIP redeem" → "Offline-verified VIP
codes". The Ed25519 signature check is fully offline, but
redeemSignedKey has rejected the redeem attempt while offline since
2.0.1 (deliberate anti-abuse gate) — the old wording implied the whole
flow works offline, which it hasn't since that release.example/README.md — a Quickstart section with the minimal setNavigatorKey/navigatorObservers/requestUmpConsent/initialize/ buildBanner snippet, so the
Docs-only. No code changes.
example/README.md — a Quickstart section with the minimal
setNavigatorKey/navigatorObservers/requestUmpConsent/initialize/
buildBanner snippet, so the pub.dev "Example" tab is self-contained
instead of only linking out to the package README.example/README.md — an index of the 16 demo pages in example/lib/main.dart (one row per page: what it demonstrates), so the pub.dev "Example" tab has
Docs-only. No code changes.
example/README.md — an index of the 16 demo pages in example/lib/main.dart
(one row per page: what it demonstrates), so the pub.dev "Example" tab has
something to navigate besides a 2,500+ line raw file.Non-breaking bug fixes, cross-checked by three independent agents (Codex, agy, a second Claude instance) with every finding verified against the sourc
Non-breaking bug fixes, cross-checked by three independent agents (Codex, agy, a second Claude instance) with every finding verified against the source. 698/698 tests pass; manually verified on a real Android device.
AdManager.initialize() bounded auto-retry. A failed adapter init
(bad ad unit ids, missing native config, transient SDK error) now retries
up to 3 times with backoff (5s/15s/30s) before giving up for the session,
instead of leaving the host permanently uninitialized until the next app
launch or an explicit re-initialize() call.onComplete now fires exactly once per host-initiated initialize()
call. Previously it could fire on every failed attempt in addition to
the terminal outcome (up to 4 times across the retry budget), violating
the 1.x callback contract of firing once with the final result.initialize() call already held the
busy guard left _isInternalInitRetryCall stuck true, causing the next
real host-initiated call to be misclassified as an internal retry.VipManager clock-rollback guard applied consistently. The
clock-rollback-resistant "now" getter (_effectiveNow, clamped against a
persisted high-water mark) was already used for expiry/stacking
calculations but was missed in _refreshGraceNudge and
_scheduleNextExpiry, which still read the raw device clock — a backward
clock jump could desync the grace-nudge and next-expiry timers from the
rest of the VIP state.VipManager.redeemSignedKey now rejects redemption attempts while the
device is offline, returning VipRedeemStatus.invalid with a
"no network connection" message, before running Ed25519 signature
verification. Deliberate anti-abuse tightening — a host at 2.0.0 that
allowed a signed key to be redeemed while offline will see those attempts
rejected at 2.0.1. Ed25519 verification itself is still fully offline
(no server call, no shared secret); only the redemption attempt now
requires connectivity.VipManager.redeemVip's separate host-supplied-validator path is not
gated by the offline check above — only redeemSignedKey is. Consumers
using redeemVip with their own validator should apply their own
connectivity check if desired.AdManager.isConnected optimistically returns true if read before the
connectivity watcher is ready, or if the platform check throws — a small
fail-open window on cold start.Breaking. Comes out of a full audit against seven production requirements (doc/audit/audit_claude_20260802.md), cross-checked by three independent age
Breaking. Comes out of a full audit against seven production requirements
(doc/audit/audit_claude_20260802.md), cross-checked by three independent
agents, with every finding verified against the source.
autoRequestUmpConsent now defaults to true. With the old defaults
(false, plus disableAppLovinCmpFlow: true and
autoShowConsentDialog: true) a host that changed nothing tripped the
consent-coverage footgun, which hard-blocks every ad request in a release
build — and the built-in dialog could not clear the block, because it applies
consent directly to the providers and never routes through setConsent().
The result was a release that requested zero ads, silently: the assert
next to the block is stripped in release, leaving one log line. Hosts that
already call requestUmpConsent() themselves are detected and the automatic
call skips, so UMP still runs exactly once.maxVipStackDuration now defaults to 90 days instead of null
(uncapped). Pass null explicitly for the old behaviour, knowing the only
remaining ceiling is the ~100-year sanity bound in the key parser.AVP2 format carrying an expiry and an
app binding inside the signed payload. AVP1 keys already issued still
verify; tool/vip_mint.dart mints AVP2 unless --v1 is passed.package_info_plus, used to read the bundle id that AVP2
keys are checked against.showAppOpenAdOnResume checked the other two fullscreen slots and the dialog
stack, but showInterstitial and showRewarded each checked only
themselves, so a call while another fullscreen ad was showing put one ad on
top of another — an AdMob and AppLovin policy violation.
AdSafetyConfig.canShowFullscreenAd() does not cover this: it is a
time-based frequency gate, not a state mutex. All three paths now share one
predicate.canReload, so a resume after a banner error (or any other caller) could
fire an ad request while canRequestAds was false, while the user was VIP,
or past the daily cap. Requesting an ad with canRequestAds == false is a
UMP policy violation, and it was invisible from the UI because the widget
layer hides banners for VIP users anyway. The canReload seam existed on
AdMobAdapter but was dead code — only AppLovin ever called it.unknown silently downgraded a stored consent. unknown
means UMP could not determine anything, not that the user refused, but it
mapped to hasUserConsent: false and overwrote a choice the user had already
made — visible in the logs as load → consent=true followed by
set → consent=false. Inconclusive results now leave the persisted value
alone. required still maps to false: there the form is genuinely needed
and was not completed.initialize().
requestConsentInfoUpdate is a callback API returning void; when the UMP
channel is not registered it throws from a future nobody awaits, so the error
escaped as an unhandled zone error that a try/catch around the call could
not catch. Unreachable while the default was false; now contained.maxVipStackDuration's docstring claimed the non-stacking path was never
clamped. It was wrong — VipManager.addVip has clamped both paths since the
single-entry cap was added. (The year-2099 legacy-GAID migration grant really
is exempt, but because it constructs its VipEntry directly.)Older entries (1.2.4 and earlier) moved to CHANGELOG_ARCHIVE.md because pub.dev rejects a CHANGELOG.md over 256 KB.
Metadata only — no code, API or behaviour change from 1.2.3.
Metadata only — no code, API or behaviour change from 1.2.3.
description and all three screenshots:
descriptions to under 160 characters. pub.dev enforces two different limits
and neither is reported by pub publish --dry-run: the upload API rejects
anything over 200 characters, while pana's scoring wants under 160 or it
drops 10 points from "Provide a valid pubspec.yaml" and another 10 from
"Package has an example and has no issues with screenshots". 1.2.3 uploaded
fine at 187-197 characters but scored 130/160 for that reason.autoRequestUmpConsent was never honoured during initialize() — a host that opted into automatic UMP now actually gets the consent request before ad re
autoRequestUmpConsent was never honoured during initialize() — a host
that opted into automatic UMP now actually gets the consent request before
ad requests start (R10-A)._retryRefillAds() returns immediately while the device is offline, instead
of burning retry budget on requests that cannot succeed (R10-C).ConnectionNotifierTools.initialize() is bounded by a 20s timeout, so a
hung connectivity plugin can no longer stall SDK init indefinitely (R10-D)._footgunBlocked leaked across re-init: one release-mode initialize()
could permanently block ads for every later init in the same process. The
same bug class then recurred for _umpRequested / _consentExplicitlySet,
so destroy() and the re-init branch now share one _resetGuardState()
instead of two hand-maintained reset lists.applyDryRunReleaseGuard()'s isRelease is threaded into the last two call
sites (the ad_manager.dart consent-footgun guard and the VipManager
constructor) that still fell back to raw kReleaseMode under flutter test.SafeLogger: critical() and e(bypassLevel: true) consolidated onto one
internal _e(); _shouldLog's bypassLevel branches merged.VipManager's isRelease parameter is no longer @visibleForTesting
(mirrors ad_safety_config.dart — the safety comes from
isActuallyRelease(), not from a compile-time restriction).repository / homepage / issue_tracker / topics and an explicit
platforms: android, ios to the pubspec; whole package reformatted with
dart format. No API or runtime change.SafeLogger's default log level is now kDebugMode-based (verbose in debug, warning-and-above in release) instead of always-verbose — a host that never
SafeLogger's default log level is now kDebugMode-based (verbose in
debug, warning-and-above in release) instead of always-verbose — a host
that never calls AdManager.setLogLevel() no longer leaks raw GAID and
other diagnostic detail into release logs by default.autoRequestUmpConsent
false + requestUmpConsent() never called before initialize()) now hard-
blocks ad requests in release builds (kReleaseMode), not just a dev-time
assert() (which strips in release and was previously log-only in
production). The block clears automatically the moment setConsent() is
called — directly by a host's own consent UI or internally by
requestUmpConsent() — and triggers a refill of any ad slots held back
while it was active.NativeAdWidget's MaxNativeAdView listener callbacks now check
adapter.isInitialised before writing to its ValueNotifiers or firing a
click event, closing the same disposed-adapter race already guarded on the
AppLovin banner/mrec views.VipManager.firstInstallGrantDueListenable — fires once when the first-install VIP grace window is granted (previously silent/log-only), paired with la
VipManager.firstInstallGrantDueListenable — fires once when the
first-install VIP grace window is granted (previously silent/log-only),
paired with lastFirstInstallGrantDuration and
acknowledgeFirstInstallGrant(). Mirrors the existing
graceNudgeDueListenable pattern. AdManager.initialize() now calls
notifyFirstInstallGrant() right after granting the window.RevenuePanel gained an optional debugModeOverride constructor param (test-only seam, @visibleForTesting) so the widget's kDebugMode gate can be exerci
RevenuePanel gained an optional debugModeOverride constructor param
(test-only seam, @visibleForTesting) so the widget's kDebugMode gate
can be exercised from flutter test.SimpleEventBus now replays the last-fired event to a listener that
subscribes after the event already fired, closing a gap where late
subscribers silently missed init-completion signals. clearAll() (called
from AdManager.destroy()) resets the replay buffer.RevenuePanel now fully gates on kDebugMode (or the override above): no
event subscription and SizedBox.shrink() render in release builds,
instead of only skipping the visual chrome.ad_manager.dart escalates the existing silent log warning for a
misconfigured consent flow (AppLovin CMP disabled, autoRequestUmpConsent
false, requestUmpConsent() never called before initialize()) to a
dev-time assert() — asserts strip in release, so production behavior is
unchanged, but dev/test builds now fail loudly instead of silently
shipping with no consent flow.requestUmpConsent() now logs a warning if called before requestAtt()
on iOS (ATT must run first per platform policy) — log-only, non-blocking.enableFillRateMonitor/enableArbitrator
are production-safe opt-in tools with no kDebugMode distinction; UMP→
AppLovin consent sync is boolean-only by design since AppLovin MAX SDK
12.0.0+ auto-reads the IAB TC-String directly; pointers to the existing
CCPA CupertinoSwitch pattern and consent_dialog.dart's binary-only
rationale for hosts that need more UI; noted the Android VIP-key
reinstall-replay limitation.example/ios/Runner/Info.plist synced from 50 → 152 SKAdNetworkItems
entries to match the host app.…connection_notifier's 3.x/4.x majors — those breaking changes only affected its widget/UI layer, which this SDK doesn't use.
confetti ^0.7.0 → ^0.8.0 and connection_notifier ^2.0.1 →
^4.1.0 (dependency freshness, closes Pub Points "up-to-date dependencies"
gap). No API surface used by this package (ConnectionNotifierTools .initialize()/.isConnected/.onStatusChange) changed across
connection_notifier's 3.x/4.x majors — those breaking changes only
affected its widget/UI layer, which this SDK doesn't use.Older entries (1.1.0 and earlier) moved to CHANGELOG_ARCHIVE.md because pub.dev rejects a CHANGELOG.md over 256 KB.
New AdSlotType.native + AdScreen.buildNative(). AdMob renders via Google's NativeAd/NativeTemplateStyle(templateType: TemplateType.medium) (same prelo
buildNative())AdSlotType.native + AdScreen.buildNative(). AdMob renders via
Google's NativeAd/NativeTemplateStyle(templateType: TemplateType.medium)
(same preload-then-AdWidget pattern as banner/MREC; the template
self-draws its own "Ad"/AdChoices label). AppLovin renders via a
self-contained MaxNativeAdView with a custom Dart child layout
(MaxNativeAdIconView/TitleView/MediaView/BodyView/
CallToActionView), for which the package self-draws its own "Ad"
compliance badge (mirrors MREC's _MrecContainer badge). v1 ships one
fixed layout — not a customizable editor. See README § "Native Ad (v1)".buildMrec())AdSlotType.mrec), same lifecycle shell
as banner (RouteAware pause/resume, VIP suppression, offline collapse,
auto-refresh gate).monetization_arbitrator.dart: opt-in per-slot provider arbitration with a
guardrail against flapping between providers. fill_rate_monitor.dart:
tracks per-slot fill rate over a rolling window for arbitration decisions
and diagnostics.AdEvent stream for host-app-side analytics.ad_diagnostics.dart / integration_self_check.dart gained checks that
catch common misconfiguration (missing ad unit IDs, mismatched provider
config) before the SDK starts requesting ads.AppLovinAdapter.preloadMrec() crashed the host app when MREC wasn't configuredAdManager.initialize() (and the VIP-loss handler) unconditionally
preload the MREC slot alongside banner, regardless of whether the host
app actually uses buildMrec(). For AppLovin, an unconfigured MREC
resolves AppLovinConfig.mrecId to its default empty string, and
AppLovin's native MaxAdViewImpl.loadAd() throws
IllegalArgumentException: No Ad Unit ID specified synchronously inside
an Android Handler callback — outside Dart's platform-channel
try/catch, so it crashed the whole process instead of surfacing as a
catchable Dart error. Any AppLovin-provider host app that doesn't
configure MREC (i.e. virtually all apps, since MREC only shipped this
release) would crash on every SDK init. preloadMrec() now skips the
native preload entirely when cfg.mrecId is empty.AppOpenTrigger.splashOnly/resumeOnly only gated the SHOW path, not LOADshowAppOpenAd/showAppOpenAdOnResume respected appOpenTrigger, but
loadAppOpenAd() (init/VIP-change/retry-refill) never checked it — under
splashOnly/resumeOnly the App Open slot kept getting refilled and could
sit ready indefinitely (AppLovin has no AdMob-style 4h TTL), wasting
network requests/fill quota. Default both was unaffected (both gates are
no-ops there). Now gated behind the same appOpenTrigger check as the show
path.redeemVip() demo mode (validator == null) no longer accepts any key in release buildsredeemVip() accepts any key when AdConfig.vipKeyValidator is
null, intended as a zero-config demo mode. A host app that forgot to wire
a validator would ship this silently — any user typing any string got free
VIP. _runValidator now refuses (return false) when validator == null
and kReleaseMode is true; debug/profile builds keep the original
demo-mode passthrough so wiring the integration still works without a
validator during development. Does not affect redeemSignedKey()
(Ed25519-verified, the path production code actually uses).> Published in two commits the same version: 2026-07-10 (consent-on-init +
Published in two commits the same version: 2026-07-10 (consent-on-init + ATT/UMP timeout fixes below) then 2026-07-16 (privacy-options timeout + CCPA toggle + SKAdNetwork expansion + doc fixes). Both are live on pub.dev under
1.0.24— the split below is historical, not two releases.
requestPrivacyOptionsFlow() could hang forever on a served-but-never-dismissed form (T44)ConsentForm.showPrivacyOptionsForm()'s own platform call resolves; awaiting
that call directly (as the code did) meant a stuck/never-dismissed form hung
the whole call with no way out — bypassing an already-added completer
timeout that could never be reached. Fixed by not awaiting
showPrivacyOptionsForm() directly (matching the existing fire-and-forget
shape already used for requestUmpConsentFlow()'s form show/dismiss) and
applying the 20s timeout to the dismiss Completer that its callback feeds.
Test: test/ump_consent_test.dart.VipRedeemScreendoNotSellValue/onDoNotSellChanged params render a switch in
the privacy footer (next to Privacy Policy/Privacy Options), letting a host
app wire it straight to AdManager().setConsent(AdConsent(doNotSell: ...)).
Opt-in only — omitting onDoNotSellChanged (the default) renders the same
footer as before.applovin_adapter.dart) all
refill ads directly from their own onAdHidden/onAdDisplayFailed native
callbacks, bypassing AdManager.loadX() and therefore its gates entirely.
A user who just redeemed VIP, went offline, or revoked consent could still
trigger an outbound ad request from a stale in-flight callback. Fixed by
adding a canReload() check (AdManager wires it to
!_isVipMember && !AdSafetyConfig.dailyCapReached() && _canRequestAds && isConnected)
immediately before every such reload call site.initialize()AdManager.setConsent() called before initialize() (the real app startup
order: requestUmpConsent() → initialize()) used to only mutate an
in-memory field and return early, because ConsentManager wasn't bootstrapped
yet. initialize()'s subsequent ConsentManager.bootstrap() then
unconditionally reloaded the previous session's stale persisted consent
and overwrote it — silently discarding the fresh UMP result on every app
launch. Fixed by buffering the pending ConsentSettings in a new
_pendingConsentSettings field and re-applying it right after bootstrap, so
it wins over the just-loaded stale data; the buffer is cleared on
destroy() so it never leaks into an unrelated future initialize(). Test:
test/consent_persistence_on_init_test.dart.initialize() forever (T43)requestAttIfNeeded() and requestUmpConsentFlow() each awaited a native
modal-dismiss (or network) callback with no timeout. If the OS/native
side never resolved it (ATT prompt throttled by rapid repeated launches;
a UMP form served but never tapped through; a dead network on
requestConsentInfoUpdate), the whole initialize() chain never ran —
the ad SDK stayed silently uninitialized for that app session. All three
awaits are now wrapped in Future.timeout(Duration(seconds: 20), onTimeout: () => <safe fallback>), mirroring the existing App-Open watchdog pattern.
requestPrivacyOptionsFlow()'s dismiss await is deliberately not part of
this fix — it's a user-initiated re-consent action outside the app-boot
gating chain. Tests: test/att_consent_test.dart, test/ump_consent_test.dart
(fakeAsync + never-completing Completer, mocking the real
google_mobile_ads UMP method channel with its custom
StandardMethodCodec(UserMessagingCodec())).ssvUserId/ssvCustomData on AdScreenState.showRewardedAd()AdManager.showRewardedAd() already accepted ssvUserId/ssvCustomData for
server-side reward verification, but the AdScreenState.showRewardedAd()
convenience wrapper (lib/src/core/ad_screen.dart) that most host screens
actually call did not expose or forward them — any caller passing those
named args failed to compile. Added both as optional parameters, forwarded
as-is to the underlying AdManager call; no change to the wrapper's
existing safety-check/disclosure-dialog behaviour.RedeemedKeyLedger (lib/src/vip/_redeemed_key_ledger.dart) backs
VipManager.redeemSignedKey's one-time-use check with an iOS Keychain
entry, alongside the existing AdPreferences (SharedPreferences) check.
AdPreferences alone is wiped on uninstall, so a user could
uninstall/reinstall to redeem the same signed key repeatedly; the Keychain
entry survives that. Android intentionally has no durable backstop here,
same reasoning as the existing FirstInstallGuard (no local primitive
survives uninstall without an install-referrer plugin for a narrow
benefit) — AdPreferences remains the sole check there. Fails open: any
Keychain read/write error is swallowed and treated as "not redeemed" so a
storage hiccup never locks out a legitimate key. Test:
test/redeemed_key_ledger_test.dart.VipManager exposes graceNudgeThreshold (default 24h),
graceNudgeDueListenable, and acknowledgeGraceNudge(). Once a VIP
entry's expiresAt comes within the threshold, the nudge notifier flips
true so the host UI can prompt the user to redeem/extend before ads
resume; acknowledging persists the current expiresAt so the same expiry
doesn't re-nudge, but stacking a new expiry (redeem/watch-ad-to-extend)
makes it due again. Inactive/no-VIP state is never due. Test:
test/vip_manager_grace_nudge_test.dart.AdPreferences.getVipEntriesRaw()/setVipEntriesRaw() now store an
FNV-1a checksum alongside the VIP entries JSON, as a single combined
SharedPreferences value ('<checksum>|<json>' written via one
setString call). A mismatched checksum is logged and treated as absent
data, deterring casual on-device editing of the plaintext VIP entries to
self-grant free ad-free time — this is a tamper deterrent, not
root/jailbreak-proof protection (a rooted device can still recompute the
checksum). Pre-upgrade data with no checksum is trusted once and
backfilled into the new format. FNV-1a was chosen over String.hashCode
(not stable across Dart/Flutter versions) and over the existing async
cryptography-package HMAC (would force every VIP-entries caller async).
Note: an earlier two-separate-keys design was replaced with the single
combined key above after it surfaced a real race — a concurrent
fire-and-forget VipManager._save() write could be observed mid-flight
with one key updated and the other still stale, causing a false
"tampered" read. Test: test/ad_preferences_test.dart.applovin_max/gma_mediation_applovin to the latest
upstream versions (4.6.4/2.6.1) to see whether the CocoaPods
version-pin conflict documented in the host pubspec.yaml
dependency_overrides had been resolved. It has not: 2.6.1 now
requires meta ^1.17.0, while flutter_test from the CI-pinned Flutter
SDK (3.35.1) forces meta 1.16.0 — a Dart-level version-solve conflict,
never even reaching the CocoaPods layer. No SDK code change; the pins
stay at applovin_max 4.6.0 / google_mobile_ads 6.0.0 /
gma_mediation_applovin 2.5.1 in the host app. See
doc/audit/audit_partner_lead_20260710.md findings #2/#3.VipRedeemScreen gained onPrivacyOptionsTap (VoidCallback?) and
VipRedeemStrings.privacyOptions, mirroring the existing
onPrivacyPolicyTap/privacyPolicy pair. The footer now renders whichever
of the two buttons has a non-null callback (previously only the privacy
policy button existed). Closes the gap where AdManager().showPrivacyOptions()
(T06) had no host call site — GDPR requires a durable re-consent entry
point, not just a one-time policy link. Test: test/vip_redeem_screen_test.dart
(footer hidden/shown/tap cases for onPrivacyOptionsTap).AdScreenState.showRewardedAd (T22)showRewardedAd gained optional disclosureTitle/disclosureSubtitle/
disclosureButtonLabel/disclosureCancelLabel params. When
disclosureTitle is set, a confirm dialog naming the reward is shown right
before the ad plays; declining calls onEarnedReward(false) and never
reaches the ad. Omitted (default): behaviour is unchanged — return type
changed void → Future<void>, non-breaking via Dart's void-return
covariance. Test: test/ad_screen_test.dart (rewarded disclosure hook
group — confirm/cancel paths).AdSafetyConfig.dailyCapReached() is a new pure read-only check (no
CTR-anomaly side effects, unlike canShowFullscreenAd()). loadAppOpenAd/
loadInterstitial/loadRewardedAd and the periodic _retryRefillAds scan
now skip preloading once the daily fullscreen-ad cap is hit — previously
the cap was only enforced at show time, so a capped-out user kept
burning ad-network load requests that could never convert. VIP members are
unaffected (the existing VIP guard already returns before this check runs
in every call site). Test: test/daily_cap_load_gate_test.dart,
test/ad_safety_config_test.dart (dailyCapReached group).VipEntry.isActive/remaining now also check now.isBefore(grantedAt) —
previously only now.isBefore(expiresAt) was checked, so rolling the
device clock backwards past a grant's expiresAt made an
already-expired-by-real-time entry "come back to life". A rolled-back
clock is now treated as the entry having already been consumed
(fail-safe), not as extra time granted. VipManager's purge/active/
stacking logic needed no change — everything already routes through
VipEntry.isActive. Test: test/vip_entry_test.dart (anti clock-rollback (T17) group).AdManager.releaseFootgunWarnings now also warns (release builds only,
same log-ERROR + assert(false, ...) treatment) when
AdConfig.firstInstallVipGrace is .disabled — previously a partner
could silently ship with no ad-free first-install trial. Test:
test/ad_manager_core_test.dart (firstInstallVipGrace cases in the
releaseFootgunWarnings group).AdManager.releaseFootgunWarnings now also warns (release builds only,
same log-ERROR + assert(false, ...) treatment as the existing dryRun/
Google-test-id guards) when: any resolved bannerId/interstitialId/
appOpenId/rewardedId is empty, or (AdMob provider only) an id doesn't
match AdMob's ca-app-pub-<16 digits>/<ad-unit id> format — the classic
"pasted an AppLovin id into the AdMob config" mistake. Test:
test/ad_manager_core_test.dart (releaseFootgunWarnings group).AdMobConfig/AppLovinConfig gained optional android*Id/ios*Id
overrides for bannerId/interstitialId/appOpenId/rewardedId (e.g.
androidBannerId, iosBannerId). Resolved via Platform.isAndroid/
Platform.isIOS at read time, falling back to the existing single id when
no override is set — fully backward compatible. Test:
test/ad_config_platform_test.dart.AdManager().isPrivacyOptionsRequired() and AdManager().showPrivacyOptions()
(wrapping ConsentInformation.getPrivacyOptionsRequirementStatus +
ConsentForm.showPrivacyOptionsForm) are now documented in the README as the
required durable re-consent entry point Google's UMP policy mandates
(a permanent "Privacy Settings" button). showPrivacyOptions() safely no-ops
(no native UI) when Google doesn't require it for the current user, and
re-applies the resulting consent to the active ad provider (npa/RDP) when it
does. Test: test/privacy_options_test.dart — required→opens form,
notRequired→no-op, re-consent re-applies to the active adapter.VipRedeemScreen widgetVipRedeemScreen + VipRedeemStrings (localizable, mirrors the
ConsentDialogStrings pattern). The host and the SDK example now render the
identical screen — host injects Vietnamese strings, the example uses the
English defaults. Privacy-policy opening is a callback (onPrivacyPolicyTap)
so the SDK needs no url_launcher dependency; confetti is added.redeemSignedKey now claims the key id atomically (synchronous
check + in-flight set) so a concurrent double-tap of the same signed key can't
slip past the one-time-use check and grant twice. Enforced in the SDK, not
just the host UI. Test: concurrent double-redeem grants exactly once.initialize() logs a loud runtime warning when AppLovin's CMP is
disabled AND autoRequestUmpConsent is false AND requestUmpConsent() was
never called before init — the "no consent form anywhere" footgun. Runtime,
not config-static, so it never false-alarms hosts that gather consent in
their splash.initRevision). Added a widget test.isConnected now falls back to the last-known connectivity state
(from the T08 watch) with a warning log instead of a silent true when the
detector is unavailable. Kept optimistic on purpose — a broken detector must
not permanently block ads; genuine offline loads just fail and back off, and
the watch refills on reconnect (network-error fast-retry is subsumed by T08).AdLoadingDialog.resetState() (called by AdManager.destroy()) now pops
a still-showing dialog before clearing its flags, so a mid-dialog destroy /
re-init can't strand a non-dismissable loading dialog on the navigator.
(_eventStream is intentionally left open — it's a process-lifetime singleton
broadcast exposed publicly; closing it would break host subscribers and it is
bounded to one instance, so it is not a leak.)isReady + atomic beginShow + null-on-dismiss), a disposed ad is never
re-shown, and dispose happens exactly once. (No code change needed; the guard
already existed — the tests lock it down.)BannerAdWidget now guards against stacking multiple post-frame
_initBanner callbacks (_initScheduled) when build runs repeatedly, so a
banner loads exactly once across rebuilds. (loadBannerIfNeeded already
bailed on a cached ad; this removes the wasteful callback pile-up.)
(dispose-before-recreate was already handled by that early-return.)AdManager.canRequestAds consent gate: every load path (app-open,
interstitial, rewarded, banner) AND every show path now skips when consent
hasn't been granted, mirroring Google UMP's ConsentInformation .canRequestAds(). requestUmpConsent stores the result and, when the gate
opens (blocked→allowed), refills the held slots. Google policy: never request
or show an ad while canRequestAds is false. Defaults true so non-UMP /
non-EEA hosts are unaffected.AdConfig.autoRequestUmpConsent (default false): when true,
initialize() runs UMP before the first ad request and gates on the result —
the SDK owns the whole consent flow. umpTagForUnderAgeOfConsent forwards the
under-age flag.AdConfig.disableAppLovinCmpFlow (default true): the AppLovin adapter
disables AppLovin's own Terms & Privacy (CMP) flow so UMP is the single
consent prompt — no double prompt. UMP's result is still forwarded to AppLovin
via setHasUserConsent.bypassSafety: true) no longer shows an
impression before consent is resolved; the show gate also prevents a
previously-loaded ad from showing after consent is revoked.verifySignedVipKey + VipManager.redeemSignedKey verify Ed25519-signed
keys offline against an embedded public key. Only the public key ships, so
a decompiler cannot forge new keys (the old local base64 map could be extracted
and reused infinitely). VIP duration is encoded in the key.AdPreferences redeemed-id store). Global one-time-use still needs a
server — documented as a known offline limitation.cryptography (pure-Dart Ed25519). Tooling: tool/vip_keygen.dart
(generate a key pair) and tool/vip_mint.dart (mint signed keys with the
private key — never shipped). See README → "Signed VIP keys".vip_keys.dart now holds only the public key + demo keys; vip_screen
redeems via redeemSignedKey.ConnectionNotifierTools (nobody did before, so
isConnected silently always returned true and the offline guards never
fired) and subscribes to onStatusChange.initRevision so banner widgets re-init
— within ~1s (debounced) instead of waiting up to 5 min for the poll timer.
Suppressed for VIP members and while uninitialised. Subscription cancelled on
destroy. Test seams: debugConnectivityChanged, debugReconnectDebounce.npa) now actually applied (T02)applyConsentToProviders only set AdMob's global
RequestConfiguration (COPPA/age tags) and never attached the per-request
non-personalized flag, so a user who declined consent could still be served
personalized AdMob ads. The doc comment claimed an npa extra was
forwarded, but no code did so.AdProviderAdapter gains applyConsent(AdConsent). AdMobAdapter maps
!hasUserConsent → AdRequest(nonPersonalizedAds: true) on every load
(banner, interstitial, rewarded, app open); it defaults to non-personalized
until consent is applied and resets to that on dispose. AppLovinAdapter's
implementation is a no-op (it forwards consent via static AppLovinMAX APIs).AdManager calls applyConsent on the adapter at init, from setConsent,
and on any ConsentManager change (auto dialog / set / reset / privacy
screen), so personalization tracks consent across every path.AdScreenRouteLogger now tracks how many PopupRoutes (dialogs, bottom sheets, Cupertino popups) are on the navigation stack and exposes AdScreenRouteLo
AdScreenRouteLogger now tracks how many PopupRoutes (dialogs, bottom
sheets, Cupertino popups) are on the navigation stack and exposes
AdScreenRouteLogger.isDialogOnTop. showAppOpenAdOnResume consults it (plus
AdLoadingDialog.isShowing) and skips the App Open ad while any dialog is
presented — e.g. the consent dialog or a VIP redeem confirmation. Showing a
fullscreen ad over a modal is bad UX and an AdMob policy risk. The counter is
reset by AdManager.destroy() so a mid-dialog teardown can't wedge it._retryRefillAds now returns immediately when the user is a VIP member.
Each load*() already guarded on VIP, so behaviour is unchanged, but this is
a defense-in-depth backstop and avoids a pointless periodic scan/log.The bundled example (example/lib/main.dart) now demonstrates the recommended consent ordering in its splash: AdManager().requestAtt() → AdManager().re
example/lib/main.dart) now demonstrates the recommended
consent ordering in its splash: AdManager().requestAtt() →
AdManager().requestUmpConsent() → AdManager().initialize(). No library /
public-API change vs 1.0.19 — upgrading requires nothing.`AdManager().requestAtt()` / `requestAttIfNeeded()` — show the iOS ATT prompt when needed and return a structured AttResult { status, idfa, allowsTrac
AdManager().requestAtt() / requestAttIfNeeded() — show the iOS ATT
prompt when needed and return a structured AttResult { status, idfa, allowsTracking } (AttStatus enum). No-op on Android; never throws (degrades
to denied). Call it in the splash before requestUmpConsent. Requires
NSUserTrackingUsageDescription in Info.plist. Decoupled from the GDPR
consent flag — the native SDKs read ATT directly for IDFA.resumed. The "foreground = hung" heuristic is now Android-only; iOS relies on
the native hidden/displayFailed callbacks plus the 90 s hard cap.AdSlot.beginReload() (genuine load failures still back off).loading before
the native BannerAd is created (fixes a synchronous-fill race).AppLovinBridge /
GmaBridge) for full behavioural unit-test coverage. No public-API change.Upgrading from 1.0.18 requires no code changes for existing integrations. To use
ATT, add NSUserTrackingUsageDescription and call AdManager().requestAtt().
Version bump only. Runtime behaviour, public API surface, and bundled assets are identical to 1.0.17. Upgrading from 1.0.17 to 1.0.18 requires no code
pubspec.yaml version bump and
flutter pub get.`FirstInstallGuard` (internal, lib/src/vip/_first_install_guard.dart) — protects the firstInstallVipGrace feature against the trivial bypass of "unins
FirstInstallGuard (internal, lib/src/vip/_first_install_guard.dart) —
protects the firstInstallVipGrace feature against the trivial bypass
of "uninstall + reinstall to claim a fresh 24-hour grace window."
Wired automatically inside AdManager.initialize; host apps need no
code changes.kSecAttrAccessibleAfterFirstUnlock, no synchronizable, no
kSecAttrAccessGroup). Keychain entries persist across app uninstall
by default on iOS, so a reinstall on the same device finds the flag
and the guard skips re-granting. Deliberately uses a constant flag
rather than identifierForVendor (IDFV) — Apple resets IDFV when the
user deletes all of a vendor's apps and reinstalls, which would let
a standalone-app reinstall silently bypass the guard.FlutterSharedPreferences.xml (which contains the
prefs.isFirstInstallGraceApplied() flag) on Play Store reinstall,
short-circuiting the outer grace block before the guard runs. The
guard itself returns false (allow grace) on Android — anti-bypass
is performed entirely by the host's AndroidManifest.xml /
<data-extraction-rules> + Google Cloud Backup.AdManager writes the Keychain
anti-bypass flag before the prefs.markFirstInstallGraceApplied()
flag, so a process kill between the two writes leaves the persistent
marker set and the next install on the same device is still blocked.kDebugMode builds skip both hasAlreadyGranted
and markGranted, so QA can iterate on flutter run without the
Keychain signal locking them out of the grace UX. Anti-bypass
validation must happen on signed release builds (TestFlight / Play
Store internal track).false (allow grace) so a transient Keychain
hiccup never denies grace to a legitimate first-time user.markGranted
no-op on Android, idempotency, and fail-open error swallowing.flutter_secure_storage: ^9.2.4 — iOS Keychain wrapper.android/app/src/main/AndroidManifest.xml:<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_content">
Create android/app/src/main/res/xml/data_extraction_rules.xml
(Android 12+) and full_backup_content.xml (Android 6-11) including
FlutterSharedPreferences.xml so Google Auto Backup restores the
grace flag on Play Store reinstall.
Without these, Android anti-bypass does not work — uninstall +
reinstall always re-grants the grace window. (Acceptable for many
apps; configure Auto Backup only if you want to block this bypass.)play_install_referrer dependency — initially included for an
Android conservative-skip path, removed after research confirmed
Install Referrer cannot detect Play Store reinstall (timestamps
reset per Google's documented behaviour). Real Android anti-bypass
comes from Auto Backup, not Install Referrer.Full English rewrite of `MIGRATION.md` — clear 1.0.14 → 1.0.15 upgrade path (no breaking changes), plus legacy 1.x → 2.x path with auto-migration deta…
README.md — restructured into 13 sections
with table of contents. Quick start expanded into 6 copy-paste steps
any Flutter developer can follow without prior AdMob/AppLovin knowledge.
Added complete public API reference, FAQ, and dedicated Pitfalls section
covering the android:taskAffinity="" issue (the most common cause of
perceived crashes during background → foreground ad cycles).MIGRATION.md — clear 1.0.14 → 1.0.15
upgrade path (no breaking changes), plus legacy 1.x → 2.x path with
auto-migration details. Added Common issues and FAQ sections.doc/architecture.md — deep-dive for
contributors and advanced integrators. Added detailed sections on the
Smart App-Open timeout, Slot-state dismiss watcher, Consent flow
sequence, Memory management contract, and Manifest pitfalls.pubspec.yaml
version bump and flutter pub get.AppLovin App-Open timeout false-positive — old fixed 10 s timeout fired while user was still interacting with the ad (click → browser → return), marki
false and arming the resume guard prematurely.
Replaced with lifecycle-aware polling: re-arms every 5 s while app is
paused (= ad still showing), force-dismisses only when app is foreground
for 2 consecutive ticks without onAdHiddenCallback (hard cap 90 s).BannerAd extends AdWithView which
has no onPaidEvent setter; previous dynamic dispatch silently failed.
Banner revenue is now correctly emitted via BannerAdListener.onPaidEvent
constructor parameter._lastFullscreenDismissAt
was recorded inside the rewarded onDone callback which fires on
reward-earned (mid-video), not on actual dismiss. Replaced with slot-state
watchers that fire on showing → !showing transition for all 3 fullscreen
slots — authoritative dismiss timestamp regardless of adapter quirks.Timer in VipManager
scheduled for the soonest expiresAt. Fires _purgeExpired + _refreshActive
when an entry expires, so the SDK reflects VIP loss without requiring a
full re-init. Especially relevant for short debug grace windows.VipManager.activeListenable; on true → false flip,
triggers loadAppOpenAd + loadInterstitial + loadRewardedAd + preloadBanner
so the user doesn't see "ad not ready" on first show after losing VIP.canShowFullscreenAd / canShowAppOpenOnResume reported "wait 0s" —
sub-second waits truncated to 0. New _fmtWait(ms) helper renders ms when
< 1 s ("wait 645ms") and 1 decimal seconds otherwise ("wait 1.5s")._armSplashBudget now detects appOpenSlot.isShowing on first elapse
and re-arms a 30 s hard cap instead of force-firing markSplashInactive.canShowAppOpenOnResume returned bool — refactored to return
AdSafetyResult (canShow + reason), aligning with canShowFullscreenAd.
Callers can log the specific block reason + remaining wait time.ConsentManager — standalone helper class owning the Cupertino consent
dialog UI, persistence (SharedPreferences), and provider apply pipeline.
Accessible via AdManager().consentManager or ConsentManager.instance.ConsentSettings — persistent user-choice record with hasBeenAsked,
askedAt, JSON serialisation.ConsentDialogStrings — localisation, includes ConsentDialogStrings.vi
for Vietnamese.AdConfig.autoShowConsentDialog (default true) — SDK auto-presents the
dialog ~1 s after markSplashInactive (post-splash, on home), not
during splash flow. Skipped for VIP users.AdConfig.consentDialogPostSplashDelay (default 1 s) — tunable.AdConfig.consentBarrierDismissible (default false).requestUmpConsentFlow(testMode, debugGeography, testIdentifiers, …) —
wraps Google's built-in ConsentInformation + ConsentForm (no extra
dependency, available since google_mobile_ads 6.x).AdManager().requestUmpConsent(…) — auto-applies UMP result to providers.ConsentStatus and DebugGeography from google_mobile_ads
so callers don't need a direct import.AdConfig.firstInstallVipGrace: FirstInstallVipGrace (default auto =
30 s in debug, 24 h in release) — auto-grants a one-time VIP entry on the
very first SDK init for this install. Improves D1 retention by giving
the freshly-installed user an ad-free first session.FirstInstallVipGrace class with auto / disabled / day /
debugShort presets and custom Duration constructor.AdConfig.firstInstallVipKey (default __FIRST_INSTALL__) — VIP entry
key for analytics discrimination.AdPreferences — new keys _keyFirstInstallApplied (one-shot guard)
and _keyFirstInstallAt (epoch-ms install timestamp for analytics).🚀 AdManager singleton CREATED marker fires once per process on cold
start. Two markers in the same logcat session = Android killed and
restarted the process.🚨 lifecycle DETACHED warning when Flutter engine tears down._safeLifecycleLog
so a closure-evaluation throw cannot abort the observer.⏭️ skipped — <reason> logs for
every gate (adapter null, VIP, no network, slot showing, safety reason)
instead of returning silently.onAdDisplayedCallback extended with network, creativeId,
placement, latencyMillis for revenue diagnosis.DebugAdOverlay — new enabled constructor flag plus static
globallyVisible ValueNotifier for runtime toggle (e.g., from a
shake-menu or dev console).AdConfig.autoShowConsentDialog skip path also covers the case where
the user redeems a VIP key during the 1 s post-splash schedule window
(re-checked at fire time).AdManager.processStartedAtMs getter.loadInterstitial / loadRewardedAd / loadAppOpenAd / showInterstitial
/ showRewardedAd / showAppOpenAd skip-path logs now name the specific
reason (adapter null vs VIP vs no network vs already showing vs safety).android:taskAffinity="" on your
MainActivity when using AppLovin. With empty affinity, AppLovin's
AppLovinFullscreenActivity lands in a different Android task; after
user backgrounds + foregrounds and dismisses the ad, the task may be
empty and the user is dropped to launcher. Default affinity (= package
name, by simply omitting the attribute) is safe.Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →