// Copyright 2022, the Flutter project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:async'; import 'dart:math'; import 'package:applovin_max/applovin_max.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:puzzleweave/ads/ad_helper.dart'; import 'package:puzzleweave/config/device.dart'; import 'package:puzzleweave/firebase/firebase_helper.dart'; import '../persistence/persistence.dart'; import '../remote_config/remote_config.dart'; import '../statistics/statistics.dart'; final Logger _log = Logger('ApplovinAdsController'); const int bannerReportDuration = 3 * 60 * 1000; // banner paid 3分钟报一次 /// Allows showing ads. A facade for `package:google_mobile_ads`. class ApplovinAdsController { final BuildContext context; late double bannerPaidValueMicros; // banner广告累计收益 late int lastBannerPaidReportTimestamp; // 上次banner广告收益上报时间戳,用于控制banner广告收益上报频率 late double allPaidValueMicros; // 所有广告累计收益 late double revenueThreshold; // 广告累计上报阈值 bool _hasInit = false; bool get hasInit => _hasInit; final Completer completer = Completer(); /// Creates an [ApplovinAdsController] that wraps around a [MobileAds] [instance]. /// /// Example usage: /// /// var controller = ApplovinAdsController(MobileAds.instance); ApplovinAdsController(this.context); void dispose() {} /// Initializes the injected [MobileAds.instance]. Future initialize() async { if (_hasInit) return; _log.info('AppLovinMAX.initialize...'); MaxConfiguration? sdkConfiguration = await AppLovinMAX.initialize(AdHelper.applovinSdkKey); _log.info('AppLovinMAX.initialize success!'); lastBannerPaidReportTimestamp = Persistence().lastBannerPaidReportTimestamp; bannerPaidValueMicros = Persistence().bannerPaidValueMicros; allPaidValueMicros = Persistence().allPaidValueMicros; revenueThreshold = RemoteConfig().adRevenueThreshold; if (revenueThreshold <= 0.0) revenueThreshold = 0.01; initializeBannerAds(); initializeInterstitialAds(); initializeRewardedAd(); completer.complete(true); _hasInit = true; } void initializeBannerAds() { AppLovinMAX.setBannerExtraParameter(AdHelper.applovinBannerAdUnitId, "adaptive_banner", "true"); // MAX automatically sizes banners to 320×50 on phones and 728×90 on tablets // 移除这一行,避免与 MaxAdView Widget 冲突! // AppLovin MAX 在 Flutter 中的最佳实践是只使用 MaxAdView Widget,让 Flutter 来管理 Banner 视图的布局和生命周期 // 同时调用 AppLovinMAX.createBanner (原生创建) 和渲染 MaxAdView (Flutter Widget),特别是在 iOS 上,原生层可能会意外地创建了一个透明的全屏视图来管理广告 // AppLovinMAX.createBanner(AdHelper.applovinBannerAdUnitId, AdViewPosition.bottomCenter); } Widget get bannerAdWidget => MaxAdView( adUnitId: AdHelper.applovinBannerAdUnitId, adFormat: AdFormat.banner, extraParameters: const {'adaptive_banner': 'true'}, listener: AdViewAdListener( onAdLoadedCallback: (ad) { // // 广告加载成功后, ad.size 包含实际的宽度和高度 (dp) // double? widthDp = ad.size?.width; // double? heightDp = ad.size?.height; // if (heightDp != null) { // context.read().bannerHeight = heightDp; // } // _log.info('banner广告 width = $widthDp, height = $heightDp'); _log.info(() => 'applovin banner Ad loaded: ${ad.hashCode}'); }, onAdLoadFailedCallback: (adUnitId, error) { _log.warning('applovin banner Ad failedToLoad: $error'); }, onAdClickedCallback: (ad) { _log.info('applovin banner Ad click registered'); }, onAdExpandedCallback: (ad) { _log.info('applovin banner Ad expanded'); }, onAdCollapsedCallback: (ad) { _log.info('applovin banner Ad collaspsed'); }, onAdRevenuePaidCallback: (ad) { _log.info('woooooooooo, applovin banner paid event: revenue: ${ad.revenue} precision: ${ad.revenuePrecision}'); if (ad.revenue > 0) { onBannerAdPaid(ad.revenue * 1000000, 'USD', ad); // revenue 单位转化为 valueMicro,以便和admod一致 } }, ), ); // banner广告收益回调的处理 onBannerAdPaid(double valueMicros, String currencyCode, MaxAd ad) async { // 原来的逻辑 bannerPaidValueMicros += valueMicros; int nowTimestamp = DateTime.now().millisecondsSinceEpoch; if ((nowTimestamp - lastBannerPaidReportTimestamp) >= bannerReportDuration) { _log.info('report banner ad paid: ${bannerPaidValueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_impression", { "ad_count": 1, "ad_platform": 'appLovin', "ad_source": ad.networkName, "ad_format": 'banner', "ad_unit_name": ad.adUnitId, "value": bannerPaidValueMicros / 1000000, "currency": "USD", }); Statistics.postEvent({ "project_id": Persistence().projectId, "user_id": Persistence().uuid, "library_name": Persistence().libraryName, "library_version": Persistence().packageVersion, "name": "revenue", "sku_id": '', "tab_source": 'play', "ad_src": 'play', "ad_count": bannerPaidValueMicros ~/ valueMicros, "ad_type": 'banner', "ad_rev": bannerPaidValueMicros / 1000000, }); bannerPaidValueMicros = 0; // 累计清零 lastBannerPaidReportTimestamp = nowTimestamp; Persistence().lastBannerPaidReportTimestamp = lastBannerPaidReportTimestamp; } Persistence().bannerPaidValueMicros = bannerPaidValueMicros; // 新的逻辑, 累计收益超过规定阈值,上报firebase和appsflyer allPaidValueMicros += valueMicros; if ((allPaidValueMicros / 1000000) >= revenueThreshold) { _log.info('report all ad paid: ${allPaidValueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_paid", {"currency": currencyCode, "value": allPaidValueMicros / 1000000}); // AppsflyerHelper.logEvent("ad_paid", {"af_currency": currencyCode, "af_revenue": allPaidValueMicros / 1000000}); allPaidValueMicros = 0; // 累计清零 } Persistence().allPaidValueMicros = allPaidValueMicros; } //////////////////////////// interstitialAd ///////////////////////////// final int _maxExponentialRetryCount = 6; var _interstitialRetryAttempt = 0; ValueNotifier interstitialAdState = ValueNotifier(AdState.initial); void initializeInterstitialAds() { AppLovinMAX.setInterstitialListener( InterstitialListener( onAdLoadedCallback: (ad) { // Interstitial ad is ready to show. AppLovinMAX.isInterstitialReady(_interstitial_ad_unit_ID) now returns 'true'. _log.info('Interstitial ad loaded from ${ad.networkName}'); // Reset retry attempt _interstitialRetryAttempt = 0; interstitialAdState.value = AdState.ready; }, onAdLoadFailedCallback: (adUnitId, error) { // Interstitial ad failed to load. // AppLovin recommends that you retry with exponentially higher delays up to a maximum delay (in this case 64 seconds). _interstitialRetryAttempt = _interstitialRetryAttempt + 1; if (_interstitialRetryAttempt > _maxExponentialRetryCount) return; int retryDelay = pow(2, min(_maxExponentialRetryCount, _interstitialRetryAttempt)).toInt(); if (kDebugMode) { _log.info('Interstitial ad failed to load with code ${error.code} - retrying in ${retryDelay}s'); } Future.delayed(Duration(milliseconds: retryDelay * 1000), () { AppLovinMAX.loadInterstitial(AdHelper.applovinInterstitialAdUnitId); }); }, onAdDisplayedCallback: (ad) { _log.info('interstitialAd displayed'); interstitialAdState.value = AdState.showing; // 广告成功展示 }, onAdDisplayFailedCallback: (ad, error) { _log.info('interstitialAd display failed'); interstitialAdState.value = AdState.error; // 广告显示异常 }, onAdClickedCallback: (ad) {}, onAdHiddenCallback: (ad) { _log.info('interstitialAd hidden'); interstitialAdState.value = AdState.dismissed; // 广告关闭 AppLovinMAX.loadInterstitial(AdHelper.applovinInterstitialAdUnitId); }, onAdRevenuePaidCallback: (ad) { _log.info('woooooooooo, applovin interstitial paid event: revenue: ${ad.revenue}, precision: ${ad.revenuePrecision}'); if (ad.revenue > 0) { onInterstitialAdPaid(ad.revenue * 1000000, 'USD', ad); // revenue 单位转化为 valueMicro,以便和admod一致 } }, ), ); // Load the first interstitial. Future.delayed(const Duration(seconds: 3), () { loadInterstitialAd(); }); } void loadInterstitialAd() async { if (!_hasInit) return; bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { return; } bool isReady = (await AppLovinMAX.isInterstitialReady(AdHelper.applovinInterstitialAdUnitId))!; if (isReady) { _log.info("applovin interstitial ad already ready, no need to load!"); return; } AppLovinMAX.loadInterstitial(AdHelper.applovinInterstitialAdUnitId); } Future isInterstitialAdReady() async { if (!_hasInit) return false; try { bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { return false; } bool isReady = (await AppLovinMAX.isInterstitialReady(AdHelper.applovinInterstitialAdUnitId))!; return isReady; } catch (e) { _log.warning('isInterstitialReady error: $e'); } return false; } String adSrc = ""; String skuId = ""; showInterstitialAd({String adSrc = "", String skuId = ""}) async { this.adSrc = adSrc; this.skuId = skuId; try { bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { return; } bool isReady = (await AppLovinMAX.isInterstitialReady(AdHelper.applovinInterstitialAdUnitId))!; if (isReady) { AppLovinMAX.showInterstitial(AdHelper.applovinInterstitialAdUnitId); } else { AppLovinMAX.loadInterstitial(AdHelper.applovinInterstitialAdUnitId); } } on PlatformException catch (e) { _log.warning('PlatformException: $e'); } catch (e) { _log.warning('showInterstitialAd: $e'); } } onInterstitialAdPaid(double valueMicros, String currencyCode, MaxAd ad) async { // 老的逻辑,每次收益都上报 _log.info('report interstitial ad paid: ${valueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_impression", { "ad_count": 1, "ad_platform": 'appLovin', "ad_source": ad.networkName, "ad_format": 'inters', "ad_unit_name": ad.adUnitId, "value": valueMicros / 1000000, "currency": "USD", }); Statistics.postEvent({ "project_id": Persistence().projectId, "user_id": Persistence().uuid, "library_name": Persistence().libraryName, "library_version": Persistence().packageVersion, "name": 'revenue', "tab_source": adSrc, "sku_id": skuId, "ad_src": adSrc, "ad_count": 1, "ad_type": "inters", "ad_rev": valueMicros / 1000000, }); // 新的逻辑, 累计收益超过规定阈值,上报firebase和appsflyer allPaidValueMicros += valueMicros; if ((allPaidValueMicros / 1000000) >= revenueThreshold) { _log.info('report all ad paid: ${allPaidValueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_paid", {"currency": currencyCode, "value": allPaidValueMicros / 1000000}); // AppsflyerHelper.logEvent("ad_paid", {"af_currency": currencyCode, "af_revenue": allPaidValueMicros / 1000000}); allPaidValueMicros = 0; // 累计清零 } Persistence().allPaidValueMicros = allPaidValueMicros; } //////////////////////////// rewardedAd ///////////////////////////// ValueNotifier rewardedAdState = ValueNotifier(AdState.initial); var _rewardedAdRetryAttempt = 0; void initializeRewardedAd() { AppLovinMAX.setRewardedAdListener( RewardedAdListener( onAdLoadedCallback: (ad) { // Rewarded ad is ready to show. AppLovinMAX.isRewardedAdReady(_rewarded_ad_unit_ID) now returns 'true'. _log.info('Rewarded ad loaded from ${ad.networkName}'); // Reset retry attempt _rewardedAdRetryAttempt = 0; rewardedAdState.value = AdState.ready; }, onAdLoadFailedCallback: (adUnitId, error) { // Rewarded ad failed to load. // AppLovin recommends that you retry with exponentially higher delays up to a maximum delay (in this case 64 seconds). _rewardedAdRetryAttempt = _rewardedAdRetryAttempt + 1; if (_rewardedAdRetryAttempt > _maxExponentialRetryCount) return; int retryDelay = pow(2, min(_maxExponentialRetryCount, _rewardedAdRetryAttempt)).toInt(); _log.info('Rewarded ad failed to load with code ${error.code} - retrying in ${retryDelay}s'); Future.delayed(Duration(milliseconds: retryDelay * 1000), () { AppLovinMAX.loadRewardedAd(AdHelper.applovinRewardedAdUnitId); }); }, onAdDisplayedCallback: (ad) { _log.info('rewardedAd displayed'); rewardedAdState.value = AdState.showing; // 广告成功展示 }, onAdDisplayFailedCallback: (ad, error) { _log.info('rewardedAd display failed'); rewardedAdState.value = AdState.error; // 广告显示异常 rewardCallback = null; }, onAdClickedCallback: (ad) {}, onAdHiddenCallback: (ad) { _log.info('rewardedAd hide'); rewardedAdState.value = AdState.dismissed; // 广告关闭 rewardCallback = null; AppLovinMAX.loadRewardedAd(AdHelper.applovinRewardedAdUnitId); }, onAdReceivedRewardCallback: (ad, reward) { _log.info('rewardedAd receive reward: $reward'); rewardCallback?.call(ad, reward); }, onAdRevenuePaidCallback: (ad) { _log.info('woooooooooo, applovin rewarded paid event: revenue: ${ad.revenue} precision: ${ad.revenuePrecision}'); if (ad.revenue > 0) { onRewardedAdPaid(ad.revenue * 1000000, 'USD', ad); // revenue 单位转化为 valueMicro,以便和admod一致 } }, ), ); // Load the first reward ad. // loadRewardedAd(); Future.delayed(const Duration(seconds: 3), () { loadRewardedAd(); }); } void loadRewardedAd() async { if (!_hasInit) return; bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { _log.warning("applovin not initialized yet!"); return; } bool isReady = (await AppLovinMAX.isRewardedAdReady(AdHelper.applovinRewardedAdUnitId))!; if (isReady) { _log.info("applovin reward ad already ready, no need to load!"); return; } AppLovinMAX.loadRewardedAd(AdHelper.applovinRewardedAdUnitId); } Future isRewardedAdReady() async { if (!_hasInit) return false; try { bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { return false; } bool isReady = (await AppLovinMAX.isRewardedAdReady(AdHelper.applovinRewardedAdUnitId))!; return isReady; } catch (e) { _log.warning('isInterstitialReady error: $e'); } return false; } Function(MaxAd, MaxReward)? rewardCallback; Future showRewardedlAd({required onUserEarnedReward, String adSrc = "", String skuId = ""}) async { this.adSrc = adSrc; this.skuId = skuId; rewardCallback = null; try { bool? isInit = await AppLovinMAX.isInitialized(); if (isInit == null || !isInit) { return false; } bool isReady = (await AppLovinMAX.isRewardedAdReady(AdHelper.applovinRewardedAdUnitId))!; if (isReady) { AppLovinMAX.showRewardedAd(AdHelper.applovinRewardedAdUnitId); rewardCallback = onUserEarnedReward; return true; } else { AppLovinMAX.loadRewardedAd(AdHelper.applovinRewardedAdUnitId); return false; } } on PlatformException catch (e) { _log.warning('PlatformException: $e'); return false; } catch (e) { _log.warning('showRewardedlAd error: $e'); return false; } } onRewardedAdPaid(double valueMicros, String currencyCode, MaxAd ad) async { // 老的逻辑,每次收益都上报 _log.info('report rewarded ad paid: ${valueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_impression", { "ad_count": 1, "ad_platform": 'appLovin', "ad_source": ad.networkName, "ad_format": 'reward', "ad_unit_name": ad.adUnitId, "value": valueMicros / 1000000, "currency": "USD", }); Statistics.postEvent({ "project_id": Persistence().projectId, "user_id": Persistence().uuid, "library_name": Persistence().libraryName, "library_version": Persistence().packageVersion, "name": 'revenue', "tab_source": adSrc, "sku_id": skuId, "ad_src": adSrc, "ad_count": 1, "ad_type": "reward", "ad_rev": valueMicros / 1000000, }); // 新的逻辑, 累计收益超过规定阈值,上报firebase和appsflyer allPaidValueMicros += valueMicros; if ((allPaidValueMicros / 1000000) >= revenueThreshold) { _log.info('report all ad paid: ${allPaidValueMicros / 1000000}$currencyCode'); FirebaseHelper.logEvent("ad_paid", {"currency": currencyCode, "value": allPaidValueMicros / 1000000}); // AppsflyerHelper.logEvent("ad_paid", {"af_currency": currencyCode, "af_revenue": allPaidValueMicros / 1000000}); allPaidValueMicros = 0; // 累计清零 } Persistence().allPaidValueMicros = allPaidValueMicros; } }