Files
fanuzu/app/lib/core/monetization/rewarded_ad.dart
T

104 lines
3.9 KiB
Dart

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<String?> show();
/// 띄우지 않고 버린다(사용자가 로딩 중 건너뛴 경우).
void dispose();
}
/// Google Mobile Ads 보상형 광고를 불러온다.
///
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
///
/// [userId] · [customData] 는 **서버측 시청 검증(AdMob SSV)** 에 실린다 — Google 이 시청 완료를
/// 서버(`fn-ad-ssv`)로 직접 알릴 때 이 두 값이 「누가 · 어느 기본 보상(txId)에 붙여」 본 광고인지
/// 가리킨다. 서버는 그 기록이 있어야만 증분을 지급한다(앱의 보상 콜백은 근거가 아니다).
Future<RewardedAdHandle> loadGoogleRewardedAd({
required String userId,
required String customData,
Duration timeout = const Duration(seconds: 15),
}) async {
await (_initialized ??= MobileAds.instance.initialize());
final loaded = Completer<RewardedAd>();
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<InitializationStatus>? _initialized;
class _GoogleRewardedAd implements RewardedAdHandle {
_GoogleRewardedAd(this._ad);
final RewardedAd _ad;
@override
Future<String?> show() async {
final closed = Completer<void>();
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());
}