1use 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
56pub const TEST_APP_ID: &str = "ca-app-pub-3940256099942544~1458002511";
61
62pub 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#[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 pub const ALL: [AdFormat; 5] = [
91 AdFormat::Banner,
92 AdFormat::Interstitial,
93 AdFormat::Rewarded,
94 AdFormat::RewardedInterstitial,
95 AdFormat::AppOpen,
96 ];
97
98 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 #[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#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
142pub enum AdLoadState {
143 #[default]
145 Idle,
146 Loading,
148 Loaded,
150 Failed,
152}
153
154#[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#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
173pub enum ConsentStatus {
174 #[default]
176 Unknown,
177 Required,
180 NotRequired,
182 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 #[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#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
209pub enum PrivacyOptionsRequirement {
210 #[default]
212 Unknown,
213 Required,
215 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
233pub enum UmpDebugGeography {
234 Eea,
236 RegulatedUsState,
238 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#[derive(Resource, Clone, Default)]
257pub struct AdmobConfig {
258 pub unit_ids: HashMap<AdFormat, String>,
262 pub test_device_ids: Vec<String>,
265 pub use_test_ads: bool,
268}
269
270impl AdmobConfig {
271 pub fn test_ads() -> Self {
274 Self {
275 use_test_ads: true,
276 ..Default::default()
277 }
278 }
279
280 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 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#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
303pub struct UmpTestConfig {
304 pub geography: Option<UmpDebugGeography>,
306 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#[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#[derive(Resource, Default)]
349pub struct AdmobState {
350 pub initialized: bool,
352 pub consent: ConsentStatus,
354 pub can_request_ads: bool,
360 pub banner_visible: bool,
362}
363
364#[derive(Message, Clone, Debug)]
370pub struct LoadAd(pub AdFormat);
371
372#[derive(Message, Clone, Debug)]
375pub struct ShowAd(pub AdFormat);
376
377#[derive(Message, Clone, Debug, Default)]
380pub struct ShowBanner {
381 pub position: BannerPosition,
382}
383
384#[derive(Message, Clone, Debug)]
386pub struct HideBanner;
387
388#[derive(Message, Clone, Debug)]
390pub struct RequestConsent;
391
392#[derive(Message, Clone, Debug)]
397pub struct PresentPrivacyOptions;
398
399#[derive(Message, Clone, Debug)]
403pub struct AdLoaded(pub AdFormat);
404
405#[derive(Message, Clone, Debug)]
407pub struct AdLoadFailed {
408 pub format: AdFormat,
409 pub error: String,
410}
411
412#[derive(Message, Clone, Debug)]
414pub struct AdShown(pub AdFormat);
415
416#[derive(Message, Clone, Debug)]
418pub struct AdDismissed(pub AdFormat);
419
420#[derive(Message, Clone, Debug)]
422pub struct AdShowFailed {
423 pub format: AdFormat,
424 pub error: String,
425}
426
427#[derive(Message, Clone, Debug)]
430pub struct RewardEarned {
431 pub format: AdFormat,
432 pub amount: i64,
433 pub reward_type: String,
434}
435
436#[derive(Message, Clone, Debug)]
438pub struct AdClicked(pub AdFormat);
439
440#[derive(Message, Clone, Debug)]
442pub struct ConsentUpdated(pub ConsentStatus);
443
444#[derive(Message, Clone, Debug)]
450pub struct ConsentInfoUpdateFailed {
451 pub error: String,
452}
453
454#[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
470fn 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#[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
572fn 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#[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#[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 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 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 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 assert_eq!(
796 cfg.resolve_unit(AdFormat::Rewarded),
797 test_unit_id(AdFormat::Rewarded)
798 );
799 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 #[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 #[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 assert_eq!(
875 app.world()
876 .resource::<AdInventory>()
877 .state(AdFormat::Interstitial),
878 AdLoadState::Idle
879 );
880 }
881
882 #[test]
884 fn rewarded_grants_reward() {
885 let _guard = guarded();
886 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 #[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 #[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(); 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(); app.update(); 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(); 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}