import 'dart:async'; import 'dart:io' show Platform; import 'package:google_mobile_ads/google_mobile_ads.dart'; import '../data/backend.dart'; import '../i18n/app_locale.dart'; /// 광고 단위 ID — **실 값으로 바꾸는 곳은 여기 한 곳이다**(앱 ID 는 AndroidManifest · /// Info.plist 의 `GADApplicationIdentifier`). /// /// 지금 값은 Google 이 공개한 **테스트 광고 단위**다(별첨 1 ⑥ 9주차 샌드박스). 테스트 /// 단위는 실 계정 없이 항상 테스트 광고를 채우고 수익·무효 트래픽 판정이 없다. abstract final class AdUnits { static const androidRewarded = 'ca-app-pub-3940256099942544/5224354917'; static const iosRewarded = 'ca-app-pub-3940256099942544/1712485313'; static String get rewarded => Platform.isIOS ? iosRewarded : androidRewarded; } /// 불러 둔 보상형 광고 한 편. /// /// 광고 네트워크 경계다. 미디에이션(AppLovin MAX 등)을 붙일 때 이 뒤만 바꾼다. abstract interface class RewardedAdHandle { /// 전면으로 띄우고 닫힐 때까지 기다린다. 끝까지 봐서 보상 콜백이 왔으면 노출 ID, /// 중간에 닫았거나 띄우지 못했으면 null. 끝나면 스스로 정리한다. Future show(); /// 띄우지 않고 버린다(사용자가 로딩 중 건너뛴 경우). void dispose(); } /// Google Mobile Ads 보상형 광고를 불러온다. /// /// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 — /// 정책 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」. /// /// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를 /// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지 /// 가리킨다. 서버는 그 기록이 있어야만 증분을 지급한다(앱의 보상 콜백은 근거가 아니다). Future loadGoogleRewardedAd({ required String userId, required String customData, Duration timeout = const Duration(seconds: 15), }) async { await (_initialized ??= MobileAds.instance.initialize()); final loaded = Completer(); await RewardedAd.load( adUnitId: AdUnits.rewarded, request: const AdRequest(), rewardedAdLoadCallback: RewardedAdLoadCallback( onAdLoaded: loaded.complete, onAdFailedToLoad: (error) => loaded.completeError(error), ), ); try { final ad = await loaded.future.timeout(timeout); await ad.setServerSideOptions( ServerSideVerificationOptions(userId: userId, customData: customData), ); return _GoogleRewardedAd(ad); } catch (_) { // 시간 초과 뒤에 늦게 도착한 광고는 띄우지 않고 버린다. unawaited(loaded.future.then((ad) => ad.dispose(), onError: (_) {})); throw BackendException('E-AD-01', appL10n.adLoadFailed); } } Future? _initialized; class _GoogleRewardedAd implements RewardedAdHandle { _GoogleRewardedAd(this._ad); final RewardedAd _ad; @override Future show() async { final closed = Completer(); String? impression; _ad.fullScreenContentCallback = FullScreenContentCallback( onAdDismissedFullScreenContent: (_) => closed.complete(), onAdFailedToShowFullScreenContent: (_, _) => closed.complete(), ); try { await _ad.show( onUserEarnedReward: (_, _) { // 화면 분기용(끝까지 봤는가)일 뿐 지급 근거가 아니다 — 근거는 SSV 콜백이다. impression = _ad.responseInfo?.responseId ?? 'gma-${DateTime.now().microsecondsSinceEpoch}'; }, ); await closed.future; } finally { // 띄우기가 던져도 네이티브 광고 객체를 남기지 않는다. await _ad.dispose(); } return impression; } @override void dispose() => unawaited(_ad.dispose()); }