Skip to main content

bevy_ios_toolkit/ads/
mod.rs

1//! Google AdMob ads as Bevy resources + messages.
2//!
3//! Flow:
4//! 1. Insert [`AdmobConfig`] with your per-format ad unit ids (or
5//!    [`AdmobConfig::test_ads`] to use Google's official sample units). The
6//!    plugin calls into the backend once to start the Mobile Ads SDK.
7//! 2. Wait until [`AdmobState::can_request_ads`] is true, presenting
8//!    [`RequestConsent`] first when consent is required.
9//! 3. Send [`LoadAd`] to preload a full-screen format; read [`AdInventory`] —
10//!    `is_loaded(format)` — to know when it's ready to present.
11//! 4. Send [`ShowAd`] to present it, or [`ShowBanner`] / [`HideBanner`] for the
12//!    banner.
13//! 5. React to [`AdLoaded`] / [`AdLoadFailed`] / [`AdShown`] / [`AdDismissed`] /
14//!    [`AdShowFailed`] / [`RewardEarned`] / [`AdClicked`].
15//!
16//! Consent: read [`AdmobState::consent`]; send [`RequestConsent`] to present the
17//! initial UMP form when required. If the [`PrivacyOptionsRequirement`]
18//! resource is [`PrivacyOptionsRequirement::Required`], keep a visible action
19//! that sends [`PresentPrivacyOptions`] so the user can revisit their choices.
20//! AdMob requires a valid consent state before serving personalized ads in
21//! regulated regions.
22//!
23//! # Desktop fake (non-iOS)
24//!
25//! On `cargo run` off-device the bridge is a deterministic in-memory fake so the
26//! full ad UX is exercisable with no SDK. It's env-tunable:
27//! - `BEVY_ADMOB_FAKE_NO_FILL=interstitial,rewarded` — those formats fail to load.
28//! - `BEVY_ADMOB_FAKE_SHOW_FAIL=interstitial` — those formats fail to present.
29//! - `BEVY_ADMOB_FAKE_REWARD_AMOUNT=10` / `BEVY_ADMOB_FAKE_REWARD_TYPE=coins` —
30//!   reward granted by rewarded formats (default `1` / `Reward`).
31//! - `BEVY_ADMOB_FAKE_CONSENT=required` — consent starts `Required` instead of
32//!   `Obtained`; a [`RequestConsent`] then resolves it to `Obtained`.
33//! - `BEVY_ADMOB_FAKE_CAN_REQUEST_ADS=true` — override authoritative UMP ad
34//!   readiness independently of its coarse consent status.
35//! - `BEVY_ADMOB_FAKE_CONSENT_UPDATE=failed|fail_once` — surface a persistent
36//!   or one-shot [`ConsentInfoUpdateFailed`].
37//! - `BEVY_ADMOB_FAKE_PRIVACY_OPTIONS=required` — the privacy-options entry
38//!   point is required for the session.
39
40use std::collections::HashMap;
41use std::ffi::CString;
42
43use bevy::prelude::*;
44use serde::{Deserialize, Serialize};
45
46use crate::ffi::read_cstr;
47
48#[cfg(target_os = "ios")]
49#[path = "backend_ios.rs"]
50mod backend;
51
52#[cfg(not(target_os = "ios"))]
53#[path = "backend_fake.rs"]
54mod backend;
55
56// ---------- Test ad units ----------
57
58/// Google's official iOS **test** app id (set as `GADApplicationIdentifier` in
59/// `Info.plist` while developing). Real ad ids only serve in production.
60pub const TEST_APP_ID: &str = "ca-app-pub-3940256099942544~1458002511";
61
62/// Google's official iOS **test** ad unit id for `format`. Always safe to
63/// request; returns fillable test creatives without risking policy strikes.
64/// <https://developers.google.com/admob/ios/test-ads>
65pub fn test_unit_id(format: AdFormat) -> &'static str {
66    match format {
67        AdFormat::Banner => "ca-app-pub-3940256099942544/2934735716",
68        AdFormat::Interstitial => "ca-app-pub-3940256099942544/4411468910",
69        AdFormat::Rewarded => "ca-app-pub-3940256099942544/1712485313",
70        AdFormat::RewardedInterstitial => "ca-app-pub-3940256099942544/6978759866",
71        AdFormat::AppOpen => "ca-app-pub-3940256099942544/5575463023",
72    }
73}
74
75// ---------- Types ----------
76
77/// An AdMob ad format. The discriminants are the stable wire values shared with
78/// the Swift bridge; do not reorder.
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Serialize, Deserialize)]
80pub enum AdFormat {
81    Banner,
82    Interstitial,
83    Rewarded,
84    RewardedInterstitial,
85    AppOpen,
86}
87
88impl AdFormat {
89    /// Every format, for iteration (inventory init, diagnostics).
90    pub const ALL: [AdFormat; 5] = [
91        AdFormat::Banner,
92        AdFormat::Interstitial,
93        AdFormat::Rewarded,
94        AdFormat::RewardedInterstitial,
95        AdFormat::AppOpen,
96    ];
97
98    /// True for the full-screen formats whose loaded creative is consumed by a
99    /// single presentation (everything except the persistent banner).
100    pub fn is_full_screen(self) -> bool {
101        !matches!(self, AdFormat::Banner)
102    }
103
104    pub fn as_i32(self) -> i32 {
105        match self {
106            AdFormat::Banner => 0,
107            AdFormat::Interstitial => 1,
108            AdFormat::Rewarded => 2,
109            AdFormat::RewardedInterstitial => 3,
110            AdFormat::AppOpen => 4,
111        }
112    }
113
114    pub fn from_i32(v: i32) -> Option<AdFormat> {
115        Some(match v {
116            0 => AdFormat::Banner,
117            1 => AdFormat::Interstitial,
118            2 => AdFormat::Rewarded,
119            3 => AdFormat::RewardedInterstitial,
120            4 => AdFormat::AppOpen,
121            _ => return None,
122        })
123    }
124
125    /// Parse the lowercase token used by the env knobs (`interstitial`, etc.).
126    /// Only the desktop fake consumes this.
127    #[cfg_attr(target_os = "ios", allow(dead_code))]
128    fn from_token(token: &str) -> Option<AdFormat> {
129        Some(match token.trim().to_ascii_lowercase().as_str() {
130            "banner" => AdFormat::Banner,
131            "interstitial" => AdFormat::Interstitial,
132            "rewarded" => AdFormat::Rewarded,
133            "rewardedinterstitial" | "rewarded_interstitial" => AdFormat::RewardedInterstitial,
134            "appopen" | "app_open" => AdFormat::AppOpen,
135            _ => return None,
136        })
137    }
138}
139
140/// Per-format load state, owned by [`AdInventory`] and advanced by events.
141#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
142pub enum AdLoadState {
143    /// Nothing loaded; the slot is empty or the loaded ad was consumed.
144    #[default]
145    Idle,
146    /// A load is in flight.
147    Loading,
148    /// A creative is loaded and ready to present.
149    Loaded,
150    /// The last load failed (no fill / network / config). Send [`LoadAd`] again.
151    Failed,
152}
153
154/// Where a banner is pinned on screen.
155#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
156pub enum BannerPosition {
157    Top,
158    #[default]
159    Bottom,
160}
161
162impl BannerPosition {
163    fn as_i32(self) -> i32 {
164        match self {
165            BannerPosition::Top => 0,
166            BannerPosition::Bottom => 1,
167        }
168    }
169}
170
171/// UMP (User Messaging Platform) consent state for personalized ads.
172#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
173pub enum ConsentStatus {
174    /// Not yet determined (SDK still resolving, or pre-init).
175    #[default]
176    Unknown,
177    /// Consent is required and not yet obtained — present the form via
178    /// [`RequestConsent`] before requesting ads.
179    Required,
180    /// Consent is not required in the user's region.
181    NotRequired,
182    /// Consent has been gathered (or is not required and already resolved).
183    Obtained,
184}
185
186impl ConsentStatus {
187    fn from_i32(v: i32) -> ConsentStatus {
188        match v {
189            1 => ConsentStatus::Required,
190            2 => ConsentStatus::NotRequired,
191            3 => ConsentStatus::Obtained,
192            _ => ConsentStatus::Unknown,
193        }
194    }
195
196    /// A coarse fallback retained for source compatibility.
197    ///
198    /// UMP's authoritative readiness is [`AdmobState::can_request_ads`]. The
199    /// native SDK can permit ads from cached consent even when this status is
200    /// `Unknown`, including after an update error.
201    #[deprecated(since = "0.3.3", note = "use AdmobState::can_request_ads")]
202    pub fn can_request_ads(self) -> bool {
203        matches!(self, Self::NotRequired | Self::Obtained)
204    }
205}
206
207/// Whether UMP requires a visible privacy-options entry point.
208#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
209pub enum PrivacyOptionsRequirement {
210    /// The consent-info refresh has not completed.
211    #[default]
212    Unknown,
213    /// Keep a visible action that sends [`PresentPrivacyOptions`].
214    Required,
215    /// No privacy-options entry point is required for this user.
216    NotRequired,
217}
218
219impl PrivacyOptionsRequirement {
220    fn from_i32(v: i32) -> Self {
221        match v {
222            1 => Self::Required,
223            2 => Self::NotRequired,
224            _ => Self::Unknown,
225        }
226    }
227}
228
229/// Test-only geography override for UMP consent flows.
230///
231/// The backend ignores every value unless [`AdmobConfig::use_test_ads`] is true.
232#[derive(Clone, Copy, PartialEq, Eq, Debug)]
233pub enum UmpDebugGeography {
234    /// Simulate a user in the European Economic Area.
235    Eea,
236    /// Simulate a user in a regulated US state.
237    RegulatedUsState,
238    /// Simulate a geography where no regulation is in force.
239    Other,
240}
241
242impl UmpDebugGeography {
243    fn as_i32(self) -> i32 {
244        match self {
245            Self::Eea => 1,
246            Self::RegulatedUsState => 3,
247            Self::Other => 4,
248        }
249    }
250}
251
252// ---------- Resources ----------
253
254/// Ad configuration. Insert before or after adding the plugin; the SDK starts
255/// on the first frame this resource exists.
256#[derive(Resource, Clone, Default)]
257pub struct AdmobConfig {
258    /// Ad unit id per format. A format with no entry here falls back to the
259    /// Google test unit (so an unconfigured format is never a hard error in
260    /// development). In production, set every format you use.
261    pub unit_ids: HashMap<AdFormat, String>,
262    /// Device ids to treat as test devices (so real ad units serve test
263    /// creatives). The SDK logs the id of each device on first request.
264    pub test_device_ids: Vec<String>,
265    /// Force Google's official test unit ids for *every* format, ignoring
266    /// `unit_ids`. Keep this on in dev; turn it off for release builds.
267    pub use_test_ads: bool,
268}
269
270impl AdmobConfig {
271    /// A config that serves Google's official test ads for every format — the
272    /// zero-setup default for development.
273    pub fn test_ads() -> Self {
274        Self {
275            use_test_ads: true,
276            ..Default::default()
277        }
278    }
279
280    /// Set the ad unit id for one format (builder-style).
281    pub fn with_unit(mut self, format: AdFormat, unit_id: impl Into<String>) -> Self {
282        self.unit_ids.insert(format, unit_id.into());
283        self
284    }
285
286    /// The effective unit id to request for `format`: the configured id, or the
287    /// Google test unit when `use_test_ads` is set or the format is unconfigured.
288    pub fn resolve_unit(&self, format: AdFormat) -> String {
289        if self.use_test_ads {
290            return test_unit_id(format).to_string();
291        }
292        match self.unit_ids.get(&format) {
293            Some(id) if !id.is_empty() => id.clone(),
294            _ => test_unit_id(format).to_string(),
295        }
296    }
297}
298
299/// Explicit UMP test overrides. Insert before [`AdsPlugin`] initializes.
300///
301/// Every override is ignored unless [`AdmobConfig::use_test_ads`] is true.
302#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
303pub struct UmpTestConfig {
304    /// Optional geography presented to UMP's debug settings.
305    pub geography: Option<UmpDebugGeography>,
306    /// Clear UMP consent state before the first consent-info refresh.
307    pub reset_consent_on_start: bool,
308}
309
310impl UmpTestConfig {
311    fn wire_values(self, use_test_ads: bool) -> (i32, i32) {
312        if use_test_ads {
313            (
314                self.geography.map(UmpDebugGeography::as_i32).unwrap_or(0),
315                self.reset_consent_on_start as i32,
316            )
317        } else {
318            (0, 0)
319        }
320    }
321}
322
323/// Per-format readiness. Read `is_loaded(format)` before sending [`ShowAd`].
324#[derive(Resource, Default)]
325pub struct AdInventory {
326    states: HashMap<AdFormat, AdLoadState>,
327}
328
329impl AdInventory {
330    pub fn state(&self, format: AdFormat) -> AdLoadState {
331        self.states.get(&format).copied().unwrap_or_default()
332    }
333
334    pub fn is_loaded(&self, format: AdFormat) -> bool {
335        self.state(format) == AdLoadState::Loaded
336    }
337
338    pub fn is_loading(&self, format: AdFormat) -> bool {
339        self.state(format) == AdLoadState::Loading
340    }
341
342    fn set(&mut self, format: AdFormat, state: AdLoadState) {
343        self.states.insert(format, state);
344    }
345}
346
347/// Coarse SDK + consent + banner state, for UI that needs the big picture.
348#[derive(Resource, Default)]
349pub struct AdmobState {
350    /// The Mobile Ads SDK has been started.
351    pub initialized: bool,
352    /// Current UMP consent state.
353    pub consent: ConsentStatus,
354    /// UMP's authoritative answer for whether ads may be requested.
355    ///
356    /// This is intentionally not inferred from [`Self::consent`]. Google may
357    /// preserve usable consent from a previous session when the current
358    /// consent-info update fails.
359    pub can_request_ads: bool,
360    /// Whether a banner is currently on screen.
361    pub banner_visible: bool,
362}
363
364// ---------- Messages (in) ----------
365
366/// Preload a full-screen ad of `format` after
367/// [`AdmobState::can_request_ads`] is true. No-op for [`AdFormat::Banner`]
368/// (use [`ShowBanner`]).
369#[derive(Message, Clone, Debug)]
370pub struct LoadAd(pub AdFormat);
371
372/// Present a previously-loaded full-screen ad. If it isn't loaded an
373/// [`AdShowFailed`] is emitted.
374#[derive(Message, Clone, Debug)]
375pub struct ShowAd(pub AdFormat);
376
377/// Show (or move) the banner after [`AdmobState::can_request_ads`] is true.
378/// Loads and displays in one step.
379#[derive(Message, Clone, Debug, Default)]
380pub struct ShowBanner {
381    pub position: BannerPosition,
382}
383
384/// Hide and release the banner.
385#[derive(Message, Clone, Debug)]
386pub struct HideBanner;
387
388/// Present the UMP consent form if one is required/available.
389#[derive(Message, Clone, Debug)]
390pub struct RequestConsent;
391
392/// Present the UMP privacy-options form in response to a visible user action.
393///
394/// Only send this while the [`PrivacyOptionsRequirement`] resource is
395/// [`PrivacyOptionsRequirement::Required`].
396#[derive(Message, Clone, Debug)]
397pub struct PresentPrivacyOptions;
398
399// ---------- Messages (out) ----------
400
401/// A load completed and a creative is ready to present.
402#[derive(Message, Clone, Debug)]
403pub struct AdLoaded(pub AdFormat);
404
405/// A load failed (no fill, network, or configuration).
406#[derive(Message, Clone, Debug)]
407pub struct AdLoadFailed {
408    pub format: AdFormat,
409    pub error: String,
410}
411
412/// A full-screen ad began presenting (good moment to pause gameplay/audio).
413#[derive(Message, Clone, Debug)]
414pub struct AdShown(pub AdFormat);
415
416/// A full-screen ad was dismissed and control returned to the app.
417#[derive(Message, Clone, Debug)]
418pub struct AdDismissed(pub AdFormat);
419
420/// Presentation failed (e.g. nothing loaded, or the OS refused).
421#[derive(Message, Clone, Debug)]
422pub struct AdShowFailed {
423    pub format: AdFormat,
424    pub error: String,
425}
426
427/// The user earned a reward from a rewarded / rewarded-interstitial ad. Grant
428/// it idempotently — this fires once per completed view.
429#[derive(Message, Clone, Debug)]
430pub struct RewardEarned {
431    pub format: AdFormat,
432    pub amount: i64,
433    pub reward_type: String,
434}
435
436/// The user tapped the ad.
437#[derive(Message, Clone, Debug)]
438pub struct AdClicked(pub AdFormat);
439
440/// The consent state changed; read [`AdmobState::consent`] for the new value.
441#[derive(Message, Clone, Debug)]
442pub struct ConsentUpdated(pub ConsentStatus);
443
444/// The latest UMP consent-info update failed.
445///
446/// Read [`AdmobState::can_request_ads`] after this message: cached consent from
447/// a previous session may still permit ads. A consumer may also offer or
448/// schedule an explicit [`RequestConsent`] retry.
449#[derive(Message, Clone, Debug)]
450pub struct ConsentInfoUpdateFailed {
451    pub error: String,
452}
453
454// ---------- Wire event (bridge -> Rust) ----------
455
456/// One event drained from the backend's queue. Mirrors the JSON the Swift shim
457/// (and the fake) emit; field names are the wire contract.
458#[derive(Serialize, Deserialize, Clone, Debug)]
459pub(crate) struct AdEvent {
460    pub format: i32,
461    pub kind: String,
462    #[serde(default)]
463    pub error: String,
464    #[serde(default)]
465    pub reward_amount: i64,
466    #[serde(default)]
467    pub reward_type: String,
468}
469
470// ---------- Safe backend wrappers ----------
471
472fn init(config: &AdmobConfig, ump_test: UmpTestConfig) {
473    let Ok(joined) = CString::new(config.test_device_ids.join(",")) else {
474        return;
475    };
476    let (debug_geography, reset_consent) = ump_test.wire_values(config.use_test_ads);
477    unsafe {
478        backend::admob_init_with_ump_test(
479            joined.as_ptr(),
480            config.use_test_ads as i32,
481            debug_geography,
482            reset_consent,
483        )
484    };
485}
486
487fn load(format: AdFormat, unit_id: &str) {
488    let Ok(unit) = CString::new(unit_id) else {
489        return;
490    };
491    unsafe { backend::admob_load(format.as_i32(), unit.as_ptr()) };
492}
493
494fn show(format: AdFormat) {
495    unsafe { backend::admob_show(format.as_i32()) };
496}
497
498fn banner_show(unit_id: &str, position: BannerPosition) {
499    let Ok(unit) = CString::new(unit_id) else {
500        return;
501    };
502    unsafe { backend::admob_banner_show(unit.as_ptr(), position.as_i32()) };
503}
504
505fn banner_hide() {
506    unsafe { backend::admob_banner_hide() };
507}
508
509fn request_consent() {
510    unsafe { backend::admob_request_consent() };
511}
512
513fn present_privacy_options() {
514    unsafe { backend::admob_present_privacy_options() };
515}
516
517fn consent_status() -> ConsentStatus {
518    ConsentStatus::from_i32(unsafe { backend::admob_consent_status() })
519}
520
521fn can_request_ads() -> bool {
522    unsafe { backend::admob_can_request_ads() != 0 }
523}
524
525fn privacy_options_requirement() -> PrivacyOptionsRequirement {
526    PrivacyOptionsRequirement::from_i32(unsafe {
527        backend::admob_privacy_options_requirement_status()
528    })
529}
530
531fn drain_events() -> Vec<AdEvent> {
532    let json = unsafe { read_cstr(backend::admob_drain_events()) };
533    serde_json::from_str(&json).unwrap_or_default()
534}
535
536// ---------- Plugin ----------
537
538#[derive(Resource, Default)]
539struct AdsPoll {
540    inited: bool,
541    consent: ConsentStatus,
542}
543
544pub struct AdsPlugin;
545
546impl Plugin for AdsPlugin {
547    fn build(&self, app: &mut App) {
548        app.init_resource::<AdInventory>()
549            .init_resource::<AdmobState>()
550            .init_resource::<PrivacyOptionsRequirement>()
551            .init_resource::<UmpTestConfig>()
552            .init_resource::<AdsPoll>()
553            .add_message::<LoadAd>()
554            .add_message::<ShowAd>()
555            .add_message::<ShowBanner>()
556            .add_message::<HideBanner>()
557            .add_message::<RequestConsent>()
558            .add_message::<PresentPrivacyOptions>()
559            .add_message::<AdLoaded>()
560            .add_message::<AdLoadFailed>()
561            .add_message::<AdShown>()
562            .add_message::<AdDismissed>()
563            .add_message::<AdShowFailed>()
564            .add_message::<RewardEarned>()
565            .add_message::<AdClicked>()
566            .add_message::<ConsentUpdated>()
567            .add_message::<ConsentInfoUpdateFailed>()
568            .add_systems(Update, (init_once, pump_requests, poll_backend).chain());
569    }
570}
571
572/// Start the SDK the first frame a [`AdmobConfig`] exists. Insertion-order
573/// tolerant — the config can land any time.
574fn init_once(
575    config: Option<Res<AdmobConfig>>,
576    ump_test: Res<UmpTestConfig>,
577    mut poll: ResMut<AdsPoll>,
578    mut state: ResMut<AdmobState>,
579) {
580    if poll.inited {
581        return;
582    }
583    if let Some(config) = config {
584        init(&config, *ump_test);
585        poll.inited = true;
586        state.initialized = true;
587    }
588}
589
590/// Forward consumer requests to the backend, resolving unit ids from config.
591#[allow(clippy::too_many_arguments)]
592fn pump_requests(
593    poll: Res<AdsPoll>,
594    config: Option<Res<AdmobConfig>>,
595    mut inventory: ResMut<AdInventory>,
596    mut state: ResMut<AdmobState>,
597    mut loads: MessageReader<LoadAd>,
598    mut shows: MessageReader<ShowAd>,
599    mut banner_shows: MessageReader<ShowBanner>,
600    mut banner_hides: MessageReader<HideBanner>,
601    mut consents: MessageReader<RequestConsent>,
602    mut privacy_options: MessageReader<PresentPrivacyOptions>,
603) {
604    if !poll.inited {
605        return;
606    }
607    let resolve = |format: AdFormat| {
608        config
609            .as_ref()
610            .map(|c| c.resolve_unit(format))
611            .unwrap_or_else(|| test_unit_id(format).to_string())
612    };
613
614    for LoadAd(format) in loads.read() {
615        if format.is_full_screen() {
616            inventory.set(*format, AdLoadState::Loading);
617            load(*format, &resolve(*format));
618        }
619    }
620    for ShowAd(format) in shows.read() {
621        show(*format);
622    }
623    for ShowBanner { position } in banner_shows.read() {
624        banner_show(&resolve(AdFormat::Banner), *position);
625        state.banner_visible = true;
626    }
627    for _ in banner_hides.read() {
628        banner_hide();
629        state.banner_visible = false;
630    }
631    for _ in consents.read() {
632        request_consent();
633    }
634    for _ in privacy_options.read() {
635        present_privacy_options();
636    }
637}
638
639/// Drain the backend's polled state into resources + messages.
640#[allow(clippy::too_many_arguments)]
641fn poll_backend(
642    mut poll: ResMut<AdsPoll>,
643    mut inventory: ResMut<AdInventory>,
644    mut state: ResMut<AdmobState>,
645    mut privacy_options_state: ResMut<PrivacyOptionsRequirement>,
646    mut loaded: MessageWriter<AdLoaded>,
647    mut load_failed: MessageWriter<AdLoadFailed>,
648    mut shown: MessageWriter<AdShown>,
649    mut dismissed: MessageWriter<AdDismissed>,
650    mut show_failed: MessageWriter<AdShowFailed>,
651    mut reward: MessageWriter<RewardEarned>,
652    mut clicked: MessageWriter<AdClicked>,
653    mut consent_updated: MessageWriter<ConsentUpdated>,
654    mut consent_update_failed: MessageWriter<ConsentInfoUpdateFailed>,
655) {
656    if !poll.inited {
657        return;
658    }
659
660    let consent = consent_status();
661    if consent != poll.consent {
662        poll.consent = consent;
663        state.consent = consent;
664        consent_updated.write(ConsentUpdated(consent));
665    }
666    let ready = can_request_ads();
667    if state.can_request_ads != ready {
668        state.can_request_ads = ready;
669    }
670    let privacy_options = privacy_options_requirement();
671    if privacy_options != *privacy_options_state {
672        *privacy_options_state = privacy_options;
673    }
674
675    for ev in drain_events() {
676        if ev.kind == "consent_update_failed" {
677            consent_update_failed.write(ConsentInfoUpdateFailed { error: ev.error });
678            continue;
679        }
680        let Some(format) = AdFormat::from_i32(ev.format) else {
681            continue;
682        };
683        match ev.kind.as_str() {
684            "loaded" => {
685                inventory.set(format, AdLoadState::Loaded);
686                loaded.write(AdLoaded(format));
687            }
688            "load_failed" => {
689                inventory.set(format, AdLoadState::Failed);
690                load_failed.write(AdLoadFailed {
691                    format,
692                    error: ev.error,
693                });
694            }
695            "shown" => {
696                // A presented full-screen creative is consumed; require a fresh load.
697                if format.is_full_screen() {
698                    inventory.set(format, AdLoadState::Idle);
699                }
700                shown.write(AdShown(format));
701            }
702            "dismissed" => {
703                dismissed.write(AdDismissed(format));
704            }
705            "show_failed" => {
706                if format.is_full_screen() {
707                    inventory.set(format, AdLoadState::Idle);
708                }
709                show_failed.write(AdShowFailed {
710                    format,
711                    error: ev.error,
712                });
713            }
714            "reward" => {
715                reward.write(RewardEarned {
716                    format,
717                    amount: ev.reward_amount,
718                    reward_type: ev.reward_type,
719                });
720            }
721            "clicked" => {
722                clicked.write(AdClicked(format));
723            }
724            _ => {}
725        }
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    /// One-shot helper systems keyed off a `Local` latch.
734    fn load_interstitial_once(mut loads: MessageWriter<LoadAd>, mut fired: Local<bool>) {
735        if !*fired {
736            *fired = true;
737            loads.write(LoadAd(AdFormat::Interstitial));
738        }
739    }
740
741    fn show_when_loaded(
742        inventory: Res<AdInventory>,
743        mut shows: MessageWriter<ShowAd>,
744        mut fired: Local<bool>,
745    ) {
746        if !*fired && inventory.is_loaded(AdFormat::Interstitial) {
747            *fired = true;
748            shows.write(ShowAd(AdFormat::Interstitial));
749        }
750    }
751
752    fn build_app() -> App {
753        let mut app = App::new();
754        app.add_plugins(MinimalPlugins);
755        app.add_plugins(AdsPlugin);
756        app
757    }
758
759    /// The fake backend is a process-global singleton, so tests that drive it
760    /// must not run concurrently. Holding this guard serializes them and resets
761    /// the fake (and clears env knobs) to a clean slate.
762    static FAKE_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
763
764    fn guarded() -> std::sync::MutexGuard<'static, ()> {
765        let g = FAKE_GUARD.lock().unwrap_or_else(|p| p.into_inner());
766        backend::reset();
767        for key in [
768            "BEVY_ADMOB_FAKE_NO_FILL",
769            "BEVY_ADMOB_FAKE_SHOW_FAIL",
770            "BEVY_ADMOB_FAKE_REWARD_AMOUNT",
771            "BEVY_ADMOB_FAKE_REWARD_TYPE",
772            "BEVY_ADMOB_FAKE_CONSENT",
773            "BEVY_ADMOB_FAKE_CAN_REQUEST_ADS",
774            "BEVY_ADMOB_FAKE_CONSENT_UPDATE",
775            "BEVY_ADMOB_FAKE_PRIVACY_OPTIONS",
776        ] {
777            unsafe { std::env::remove_var(key) };
778        }
779        g
780    }
781
782    #[test]
783    fn format_i32_round_trips() {
784        for f in AdFormat::ALL {
785            assert_eq!(AdFormat::from_i32(f.as_i32()), Some(f));
786        }
787        assert_eq!(AdFormat::from_i32(99), None);
788    }
789
790    #[test]
791    fn resolve_unit_prefers_config_then_falls_back_to_test() {
792        let cfg = AdmobConfig::default().with_unit(AdFormat::Interstitial, "ca-app-pub-x/y");
793        assert_eq!(cfg.resolve_unit(AdFormat::Interstitial), "ca-app-pub-x/y");
794        // Unconfigured format falls back to the Google test unit.
795        assert_eq!(
796            cfg.resolve_unit(AdFormat::Rewarded),
797            test_unit_id(AdFormat::Rewarded)
798        );
799        // use_test_ads overrides everything.
800        let test_cfg = AdmobConfig::test_ads().with_unit(AdFormat::Interstitial, "ca-app-pub-x/y");
801        assert_eq!(
802            test_cfg.resolve_unit(AdFormat::Interstitial),
803            test_unit_id(AdFormat::Interstitial)
804        );
805    }
806
807    #[test]
808    fn consent_debug_overrides_are_disabled_for_production_ads() {
809        let _guard = guarded();
810        let mut app = build_app();
811        app.insert_resource(AdmobConfig::default());
812        app.insert_resource(UmpTestConfig {
813            geography: Some(UmpDebugGeography::Eea),
814            reset_consent_on_start: true,
815        });
816        app.update();
817
818        assert_eq!(backend::ump_test_config(), (0, false));
819    }
820
821    /// Accumulates terminal outcomes across frames so assertions don't race the
822    /// double-buffered message swap (a real game would react frame-by-frame).
823    #[derive(Resource, Default)]
824    struct Recorder {
825        loaded: u32,
826        dismissed: u32,
827        rewards: Vec<RewardEarned>,
828    }
829
830    fn record(
831        mut rec: ResMut<Recorder>,
832        mut loaded: MessageReader<AdLoaded>,
833        mut dismissed: MessageReader<AdDismissed>,
834        mut rewards: MessageReader<RewardEarned>,
835    ) {
836        rec.loaded += loaded.read().count() as u32;
837        rec.dismissed += dismissed.read().count() as u32;
838        for r in rewards.read() {
839            rec.rewards.push(r.clone());
840        }
841    }
842
843    fn show_rewarded_once(
844        inv: Res<AdInventory>,
845        mut shows: MessageWriter<ShowAd>,
846        mut fired: Local<bool>,
847    ) {
848        if !*fired && inv.is_loaded(AdFormat::Rewarded) {
849            *fired = true;
850            shows.write(ShowAd(AdFormat::Rewarded));
851        }
852    }
853
854    /// End-to-end against the fake: config → load → loaded → show → dismissed.
855    #[test]
856    fn interstitial_load_show_dismiss_flow() {
857        let _guard = guarded();
858        let mut app = build_app();
859        app.init_resource::<Recorder>();
860        app.insert_resource(AdmobConfig::test_ads());
861        app.add_systems(Update, (load_interstitial_once, show_when_loaded, record));
862
863        for _ in 0..10 {
864            app.update();
865        }
866
867        let rec = app.world().resource::<Recorder>();
868        assert_eq!(
869            rec.loaded, 1,
870            "interstitial should have reported loaded once"
871        );
872        assert_eq!(rec.dismissed, 1, "interstitial should have dismissed once");
873        // Consumed after showing.
874        assert_eq!(
875            app.world()
876                .resource::<AdInventory>()
877                .state(AdFormat::Interstitial),
878            AdLoadState::Idle
879        );
880    }
881
882    /// A rewarded ad grants a reward when shown.
883    #[test]
884    fn rewarded_grants_reward() {
885        let _guard = guarded();
886        // SAFETY: `guarded()` serializes fake-driving tests, so this env state is
887        // not observed by any concurrent test.
888        unsafe {
889            std::env::set_var("BEVY_ADMOB_FAKE_REWARD_AMOUNT", "7");
890            std::env::set_var("BEVY_ADMOB_FAKE_REWARD_TYPE", "gems");
891        }
892
893        let mut app = build_app();
894        app.init_resource::<Recorder>();
895        app.insert_resource(AdmobConfig::test_ads());
896        app.add_systems(
897            Update,
898            (
899                |mut loads: MessageWriter<LoadAd>, mut fired: Local<bool>| {
900                    if !*fired {
901                        *fired = true;
902                        loads.write(LoadAd(AdFormat::Rewarded));
903                    }
904                },
905                show_rewarded_once,
906                record,
907            ),
908        );
909
910        for _ in 0..10 {
911            app.update();
912        }
913
914        let rec = app.world().resource::<Recorder>();
915        assert_eq!(
916            rec.rewards.len(),
917            1,
918            "rewarded ad should grant exactly one reward"
919        );
920        assert_eq!(rec.rewards[0].amount, 7);
921        assert_eq!(rec.rewards[0].reward_type, "gems");
922        assert_eq!(rec.dismissed, 1, "rewarded ad should also dismiss");
923    }
924
925    /// A no-fill load surfaces as `Failed`, not `Loaded`.
926    #[test]
927    fn no_fill_reports_failure() {
928        let _guard = guarded();
929        unsafe { std::env::set_var("BEVY_ADMOB_FAKE_NO_FILL", "appopen") }
930
931        let mut app = build_app();
932        app.insert_resource(AdmobConfig::test_ads());
933        app.add_systems(
934            Update,
935            |mut loads: MessageWriter<LoadAd>, mut fired: Local<bool>| {
936                if !*fired {
937                    *fired = true;
938                    loads.write(LoadAd(AdFormat::AppOpen));
939                }
940            },
941        );
942
943        for _ in 0..6 {
944            app.update();
945        }
946
947        assert_eq!(
948            app.world()
949                .resource::<AdInventory>()
950                .state(AdFormat::AppOpen),
951            AdLoadState::Failed
952        );
953    }
954
955    /// Consent starts `Required`, then `RequestConsent` resolves it to
956    /// `Obtained` and emits a `ConsentUpdated`.
957    #[test]
958    fn consent_required_then_obtained() {
959        let _guard = guarded();
960        unsafe { std::env::set_var("BEVY_ADMOB_FAKE_CONSENT", "required") }
961
962        let mut app = build_app();
963        app.insert_resource(AdmobConfig::test_ads());
964
965        app.update(); // init + first consent poll
966        assert_eq!(
967            app.world().resource::<AdmobState>().consent,
968            ConsentStatus::Required
969        );
970
971        app.world_mut()
972            .resource_mut::<Messages<RequestConsent>>()
973            .write(RequestConsent);
974        app.update(); // pump request
975        app.update(); // poll the new status
976
977        assert_eq!(
978            app.world().resource::<AdmobState>().consent,
979            ConsentStatus::Obtained
980        );
981        assert!(app.world().resource::<AdmobState>().can_request_ads);
982    }
983
984    #[test]
985    fn native_readiness_is_not_inferred_from_coarse_consent_status() {
986        let _guard = guarded();
987        unsafe {
988            std::env::set_var("BEVY_ADMOB_FAKE_CONSENT", "unknown");
989            std::env::set_var("BEVY_ADMOB_FAKE_CAN_REQUEST_ADS", "true");
990        }
991
992        let mut app = build_app();
993        app.insert_resource(AdmobConfig::test_ads());
994        app.update();
995
996        let state = app.world().resource::<AdmobState>();
997        assert_eq!(state.consent, ConsentStatus::Unknown);
998        assert!(state.can_request_ads);
999    }
1000
1001    #[derive(Resource, Default)]
1002    struct ConsentFailureRecorder(Vec<String>);
1003
1004    fn record_consent_failures(
1005        mut recorder: ResMut<ConsentFailureRecorder>,
1006        mut failures: MessageReader<ConsentInfoUpdateFailed>,
1007    ) {
1008        recorder
1009            .0
1010            .extend(failures.read().map(|failure| failure.error.clone()));
1011    }
1012
1013    #[test]
1014    fn consent_update_failure_is_observable_and_can_recover() {
1015        let _guard = guarded();
1016        unsafe {
1017            std::env::set_var("BEVY_ADMOB_FAKE_CONSENT", "unknown");
1018            std::env::set_var("BEVY_ADMOB_FAKE_CONSENT_UPDATE", "fail_once");
1019        }
1020
1021        let mut app = build_app();
1022        app.init_resource::<ConsentFailureRecorder>()
1023            .insert_resource(AdmobConfig::test_ads())
1024            .add_systems(Update, record_consent_failures);
1025        app.update();
1026        app.update();
1027
1028        assert!(!app.world().resource::<AdmobState>().can_request_ads);
1029        assert_eq!(app.world().resource::<ConsentFailureRecorder>().0.len(), 1);
1030
1031        app.world_mut()
1032            .resource_mut::<Messages<RequestConsent>>()
1033            .write(RequestConsent);
1034        app.update();
1035        app.update();
1036
1037        let state = app.world().resource::<AdmobState>();
1038        assert_eq!(state.consent, ConsentStatus::Obtained);
1039        assert!(state.can_request_ads);
1040    }
1041
1042    #[test]
1043    fn required_privacy_options_are_polled_and_presented_from_a_request() {
1044        let _guard = guarded();
1045        unsafe { std::env::set_var("BEVY_ADMOB_FAKE_PRIVACY_OPTIONS", "required") }
1046
1047        let mut app = build_app();
1048        app.insert_resource(AdmobConfig::test_ads());
1049        app.update();
1050
1051        assert_eq!(
1052            *app.world().resource::<PrivacyOptionsRequirement>(),
1053            PrivacyOptionsRequirement::Required
1054        );
1055        assert_eq!(backend::privacy_options_presentations(), 0);
1056
1057        app.world_mut()
1058            .resource_mut::<Messages<PresentPrivacyOptions>>()
1059            .write(PresentPrivacyOptions);
1060        app.update();
1061
1062        assert_eq!(backend::privacy_options_presentations(), 1);
1063        assert_eq!(
1064            *app.world().resource::<PrivacyOptionsRequirement>(),
1065            PrivacyOptionsRequirement::Required
1066        );
1067    }
1068
1069    #[test]
1070    fn privacy_options_are_not_required_without_a_fake_override() {
1071        let _guard = guarded();
1072        let mut app = build_app();
1073        app.insert_resource(AdmobConfig::test_ads());
1074        app.update();
1075
1076        assert_eq!(
1077            *app.world().resource::<PrivacyOptionsRequirement>(),
1078            PrivacyOptionsRequirement::NotRequired
1079        );
1080    }
1081
1082    #[test]
1083    fn every_debug_geography_and_reset_reach_the_backend_for_test_ads() {
1084        let _guard = guarded();
1085        for (geography, wire) in [
1086            (UmpDebugGeography::Eea, 1),
1087            (UmpDebugGeography::RegulatedUsState, 3),
1088            (UmpDebugGeography::Other, 4),
1089        ] {
1090            backend::reset();
1091            let mut app = build_app();
1092            app.insert_resource(AdmobConfig::test_ads());
1093            app.insert_resource(UmpTestConfig {
1094                geography: Some(geography),
1095                reset_consent_on_start: true,
1096            });
1097            app.update();
1098            assert_eq!(backend::ump_test_config(), (wire, true));
1099        }
1100    }
1101
1102    #[test]
1103    fn banner_show_hide_toggles_state() {
1104        let _guard = guarded();
1105        let mut app = build_app();
1106        app.insert_resource(AdmobConfig::test_ads());
1107
1108        app.update(); // init
1109        app.world_mut()
1110            .resource_mut::<Messages<ShowBanner>>()
1111            .write(ShowBanner::default());
1112        app.update();
1113        assert!(app.world().resource::<AdmobState>().banner_visible);
1114
1115        app.world_mut()
1116            .resource_mut::<Messages<HideBanner>>()
1117            .write(HideBanner);
1118        app.update();
1119        assert!(!app.world().resource::<AdmobState>().banner_visible);
1120    }
1121}