- core/di/: service_locator, IIAPService, IAdService 생성 - IAPService/AdService: implements 인터페이스 + GetIt 위임 - main.dart: setupServiceLocator() 호출 - 기존 .instance getter 호환성 100% 유지 - test/helpers/test_setup.dart: 테스트용 서비스 로케이터 초기화
508 lines
16 KiB
Dart
508 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'package:pointycastle/export.dart' as pc;
|
|
import 'package:pointycastle/asn1/asn1_parser.dart';
|
|
import 'package:pointycastle/asn1/primitives/asn1_bit_string.dart';
|
|
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
|
|
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
|
|
|
|
import 'package:get_it/get_it.dart';
|
|
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
|
import 'package:asciineverdie/src/core/di/i_iap_service.dart';
|
|
|
|
/// IAP 상품 ID
|
|
class IAPProductIds {
|
|
IAPProductIds._();
|
|
|
|
/// 광고 제거 상품 ID (비소모성)
|
|
static const String removeAds = 'remove_ads_and';
|
|
|
|
/// 모든 상품 ID 목록
|
|
static const Set<String> all = {removeAds};
|
|
}
|
|
|
|
/// IAP 구매 결과
|
|
enum IAPResult {
|
|
/// 구매 성공
|
|
success,
|
|
|
|
/// 구매 취소
|
|
cancelled,
|
|
|
|
/// 구매 실패
|
|
failed,
|
|
|
|
/// 이미 구매됨
|
|
alreadyPurchased,
|
|
|
|
/// 상품을 찾을 수 없음
|
|
productNotFound,
|
|
|
|
/// 스토어 사용 불가
|
|
storeUnavailable,
|
|
|
|
/// 디버그 모드에서 시뮬레이션
|
|
debugSimulated,
|
|
}
|
|
|
|
/// Google Play 공개키 (public key)
|
|
///
|
|
/// Google Play Console > 수익 창출(Monetization) 설정 > 라이선스(Licensing)에서
|
|
/// Base64 인코딩된 RSA 공개키를 복사하여 아래에 붙여넣기.
|
|
/// 공개키이므로 비밀이 아니지만, 바이너리 스캔 방지를 위해 분할 저장.
|
|
const _gpKeyPart1 = 'YOUR_GOOGLE_PLAY_';
|
|
const _gpKeyPart2 = 'PUBLIC_KEY_HERE';
|
|
String get _googlePlayPublicKey => _gpKeyPart1 + _gpKeyPart2;
|
|
|
|
/// IAP 서비스
|
|
///
|
|
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
|
/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다.
|
|
class IAPService implements IIAPService {
|
|
IAPService._();
|
|
|
|
/// GetIt 등록용 팩토리 메서드
|
|
factory IAPService.createInstance() => IAPService._();
|
|
|
|
/// 싱글톤 인스턴스 (GetIt 서비스 로케이터에 위임)
|
|
static IIAPService get instance => GetIt.instance<IIAPService>();
|
|
|
|
// ===========================================================================
|
|
// 상수
|
|
// ===========================================================================
|
|
|
|
/// 구매 상태 저장 키
|
|
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
|
|
|
/// 보안 저장소 (secure storage) 인스턴스
|
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
|
|
|
// ===========================================================================
|
|
// 상태
|
|
// ===========================================================================
|
|
|
|
late final InAppPurchase _iap = InAppPurchase.instance;
|
|
|
|
bool _isInitialized = false;
|
|
bool _isAvailable = false;
|
|
|
|
/// 상품 정보
|
|
ProductDetails? _removeAdsProduct;
|
|
|
|
/// 구매 스트림 구독
|
|
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
|
|
|
/// 광고 제거 구매 여부 (캐시)
|
|
bool _adRemovalPurchased = false;
|
|
|
|
/// 디버그 모드에서 IAP 시뮬레이션 활성화 여부
|
|
bool _debugIAPSimulated = false;
|
|
|
|
// ===========================================================================
|
|
// 초기화
|
|
// ===========================================================================
|
|
|
|
/// IAP 서비스 초기화
|
|
@override
|
|
Future<void> initialize() async {
|
|
if (_isInitialized) return;
|
|
|
|
// 모바일 플랫폼에서만 초기화
|
|
if (!Platform.isAndroid && !Platform.isIOS) {
|
|
debugPrint('[IAPService] Non-mobile platform, skipping initialization');
|
|
return;
|
|
}
|
|
|
|
// 저장된 구매 상태 로드
|
|
await _loadPurchaseState();
|
|
|
|
// 스토어 가용성 확인
|
|
_isAvailable = await _iap.isAvailable();
|
|
if (!_isAvailable) {
|
|
debugPrint('[IAPService] Store not available');
|
|
_isInitialized = true;
|
|
return;
|
|
}
|
|
|
|
// 구매 스트림 구독
|
|
_subscription = _iap.purchaseStream.listen(
|
|
_onPurchaseUpdate,
|
|
onError: (Object error) {
|
|
debugPrint('[IAPService] Purchase stream error: $error');
|
|
},
|
|
);
|
|
|
|
// 상품 정보 로드
|
|
await _loadProducts();
|
|
|
|
_isInitialized = true;
|
|
debugPrint('[IAPService] Initialized');
|
|
|
|
// 로컬에 구매 기록이 없으면 스토어에서 자동 복원 시도
|
|
// (앱 삭제 후 재설치 대응, 비동기로 실행하여 초기화 블로킹 없음)
|
|
if (!_adRemovalPurchased) {
|
|
_tryAutoRestore();
|
|
}
|
|
}
|
|
|
|
/// 상품 정보 로드
|
|
Future<void> _loadProducts() async {
|
|
final response = await _iap.queryProductDetails(IAPProductIds.all);
|
|
|
|
if (response.notFoundIDs.isNotEmpty) {
|
|
debugPrint('[IAPService] Products not found: ${response.notFoundIDs}');
|
|
}
|
|
|
|
for (final product in response.productDetails) {
|
|
if (product.id == IAPProductIds.removeAds) {
|
|
_removeAdsProduct = product;
|
|
debugPrint(
|
|
'[IAPService] Product loaded: ${product.id} - ${product.price}',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 앱 시작 시 자동 구매 복원 (재설치 대응)
|
|
///
|
|
/// purchaseStream 구독 후 호출되므로, 복원된 구매는
|
|
/// _onPurchaseUpdate → _handleSuccessfulPurchase로 처리됨.
|
|
void _tryAutoRestore() {
|
|
debugPrint('[IAPService] Attempting auto-restore...');
|
|
_iap.restorePurchases().catchError((Object error) {
|
|
debugPrint('[IAPService] Auto-restore failed: $error');
|
|
});
|
|
}
|
|
|
|
/// 저장된 구매 상태 로드 (보안 저장소 사용)
|
|
Future<void> _loadPurchaseState() async {
|
|
final value = await _secureStorage.read(key: _purchaseKey);
|
|
_adRemovalPurchased = value == 'true';
|
|
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
|
}
|
|
|
|
/// 구매 상태 저장 (보안 저장소 사용)
|
|
Future<void> _savePurchaseState(bool purchased) async {
|
|
await _secureStorage.write(
|
|
key: _purchaseKey,
|
|
value: purchased.toString(),
|
|
);
|
|
_adRemovalPurchased = purchased;
|
|
debugPrint('[IAPService] Saved purchase state: $purchased');
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 디버그 설정
|
|
// ===========================================================================
|
|
|
|
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
|
|
@override
|
|
bool get debugIAPSimulated => _debugIAPSimulated;
|
|
|
|
/// 디버그 모드 IAP 시뮬레이션 토글
|
|
@override
|
|
set debugIAPSimulated(bool value) {
|
|
_debugIAPSimulated = value;
|
|
if (kDebugMode) {
|
|
_adRemovalPurchased = value;
|
|
debugPrint('[IAPService] Debug IAP simulated: $value');
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 구매 상태
|
|
// ===========================================================================
|
|
|
|
/// 광고 제거 구매 여부
|
|
@override
|
|
bool get isAdRemovalPurchased {
|
|
if (kDebugMode && _debugIAPSimulated) return true;
|
|
return _adRemovalPurchased;
|
|
}
|
|
|
|
/// 스토어 가용성
|
|
@override
|
|
bool get isStoreAvailable => _isAvailable;
|
|
|
|
/// 광고 제거 상품 정보
|
|
ProductDetails? get removeAdsProduct => _removeAdsProduct;
|
|
|
|
/// 광고 제거 상품 가격 문자열
|
|
@override
|
|
String get removeAdsPrice {
|
|
if (_removeAdsProduct != null) {
|
|
return _removeAdsProduct!.price;
|
|
}
|
|
// 스토어 미연결 시 로케일별 대체 가격
|
|
return switch (game_l10n.currentGameLocale) {
|
|
'ko' => '₩9,900',
|
|
'ja' => '¥990',
|
|
_ => '\$9.99',
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 구매
|
|
// ===========================================================================
|
|
|
|
/// 광고 제거 구매
|
|
@override
|
|
Future<IAPResult> purchaseRemoveAds() async {
|
|
// 디버그 모드 시뮬레이션
|
|
if (kDebugMode && _debugIAPSimulated) {
|
|
debugPrint('[IAPService] Debug: Simulating purchase');
|
|
await _savePurchaseState(true);
|
|
return IAPResult.debugSimulated;
|
|
}
|
|
|
|
// 이미 구매됨
|
|
if (_adRemovalPurchased) {
|
|
debugPrint('[IAPService] Already purchased');
|
|
return IAPResult.alreadyPurchased;
|
|
}
|
|
|
|
// 스토어 사용 불가
|
|
if (!_isAvailable) {
|
|
debugPrint('[IAPService] Store not available');
|
|
return IAPResult.storeUnavailable;
|
|
}
|
|
|
|
// 상품을 찾을 수 없음
|
|
if (_removeAdsProduct == null) {
|
|
debugPrint('[IAPService] Product not found');
|
|
return IAPResult.productNotFound;
|
|
}
|
|
|
|
// 구매 요청
|
|
final purchaseParam = PurchaseParam(productDetails: _removeAdsProduct!);
|
|
|
|
try {
|
|
final success = await _iap.buyNonConsumable(purchaseParam: purchaseParam);
|
|
debugPrint('[IAPService] Purchase initiated: $success');
|
|
return success ? IAPResult.success : IAPResult.failed;
|
|
} catch (e) {
|
|
debugPrint('[IAPService] Purchase error: $e');
|
|
return IAPResult.failed;
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 구매 복원
|
|
// ===========================================================================
|
|
|
|
/// 구매 복원
|
|
@override
|
|
Future<IAPResult> restorePurchases() async {
|
|
// 디버그 모드 시뮬레이션
|
|
if (kDebugMode && _debugIAPSimulated) {
|
|
debugPrint('[IAPService] Debug: Simulating restore');
|
|
await _savePurchaseState(true);
|
|
return IAPResult.debugSimulated;
|
|
}
|
|
|
|
// 스토어 사용 불가
|
|
if (!_isAvailable) {
|
|
debugPrint('[IAPService] Store not available');
|
|
return IAPResult.storeUnavailable;
|
|
}
|
|
|
|
try {
|
|
await _iap.restorePurchases();
|
|
debugPrint('[IAPService] Restore initiated');
|
|
return IAPResult.success;
|
|
} catch (e) {
|
|
debugPrint('[IAPService] Restore error: $e');
|
|
return IAPResult.failed;
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 구매 처리
|
|
// ===========================================================================
|
|
|
|
/// 구매 업데이트 처리
|
|
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
|
|
for (final purchase in purchases) {
|
|
debugPrint(
|
|
'[IAPService] Purchase update: ${purchase.productID} - '
|
|
'${purchase.status}',
|
|
);
|
|
|
|
switch (purchase.status) {
|
|
case PurchaseStatus.pending:
|
|
// 구매 대기 중
|
|
debugPrint('[IAPService] Purchase pending');
|
|
|
|
case PurchaseStatus.purchased:
|
|
case PurchaseStatus.restored:
|
|
// 구매 완료 또는 복원
|
|
_handleSuccessfulPurchase(purchase);
|
|
|
|
case PurchaseStatus.error:
|
|
// 구매 실패
|
|
debugPrint('[IAPService] Purchase error: ${purchase.error}');
|
|
_completePurchase(purchase);
|
|
|
|
case PurchaseStatus.canceled:
|
|
// 구매 취소
|
|
debugPrint('[IAPService] Purchase canceled');
|
|
_completePurchase(purchase);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 구매 성공 처리 (서명 검증 포함)
|
|
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
|
if (purchase.productID == IAPProductIds.removeAds) {
|
|
// 플랫폼별 로컬 영수증 검증 (local receipt verification)
|
|
final verified = _verifyPurchaseLocally(purchase);
|
|
if (!verified) {
|
|
debugPrint('[IAPService] Purchase verification FAILED - not granting');
|
|
await _completePurchase(purchase);
|
|
return;
|
|
}
|
|
|
|
await _savePurchaseState(true);
|
|
debugPrint('[IAPService] Ad removal purchased & verified successfully');
|
|
}
|
|
|
|
// 구매 완료 처리
|
|
await _completePurchase(purchase);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 로컬 영수증 검증 (local receipt verification)
|
|
// ===========================================================================
|
|
|
|
/// 플랫폼별 로컬 구매 검증
|
|
///
|
|
/// Google Play: RSASSA-PKCS1-v1_5 + SHA1 서명 검증
|
|
/// Apple: StoreKit 2 플러그인 자체 검증 (향후 JWS 로컬 검증 추가 예정)
|
|
bool _verifyPurchaseLocally(PurchaseDetails purchase) {
|
|
final source = purchase.verificationData.source;
|
|
|
|
if (source == 'google_play') {
|
|
return _verifyGooglePlayPurchase(purchase);
|
|
} else if (source == 'app_store') {
|
|
// TODO: Apple StoreKit 2 JWS 로컬 검증 추가
|
|
// 현재는 in_app_purchase 플러그인의 네이티브 검증에 의존
|
|
debugPrint('[IAPService] Apple purchase - plugin-native verification');
|
|
return true;
|
|
}
|
|
|
|
debugPrint('[IAPService] Unknown source: $source - skipping verification');
|
|
return true;
|
|
}
|
|
|
|
/// Google Play 구매 RSA 서명 검증
|
|
///
|
|
/// localVerificationData는 Base64 인코딩된 서명 JSON 데이터.
|
|
/// serverVerificationData에 원본 구매 JSON이 포함됨.
|
|
bool _verifyGooglePlayPurchase(PurchaseDetails purchase) {
|
|
try {
|
|
final signedData = purchase.verificationData.serverVerificationData;
|
|
final signature = purchase.verificationData.localVerificationData;
|
|
|
|
if (signedData.isEmpty || signature.isEmpty) {
|
|
debugPrint('[IAPService] Empty verification data');
|
|
return false;
|
|
}
|
|
|
|
return _verifyRsaSignature(signedData, signature, _googlePlayPublicKey);
|
|
} catch (e) {
|
|
debugPrint('[IAPService] Google Play verification error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// RSASSA-PKCS1-v1_5 + SHA1 서명 검증
|
|
///
|
|
/// Google Play 서명 검증에 사용되는 RSA 알고리즘.
|
|
/// [data]: 서명된 원본 데이터 (signed data)
|
|
/// [signatureBase64]: Base64 인코딩된 서명 (signature)
|
|
/// [publicKeyBase64]: Base64 인코딩된 DER 공개키 (public key)
|
|
bool _verifyRsaSignature(
|
|
String data,
|
|
String signatureBase64,
|
|
String publicKeyBase64,
|
|
) {
|
|
try {
|
|
// 공개키 DER 바이트 디코딩 (decode public key)
|
|
final publicKeyBytes = base64Decode(publicKeyBase64);
|
|
final publicKey = _parsePublicKeyFromDer(publicKeyBytes);
|
|
|
|
// 서명 디코딩 (decode signature)
|
|
final signatureBytes = base64Decode(signatureBase64);
|
|
|
|
// 원본 데이터를 UTF-8 바이트로 변환
|
|
final dataBytes = Uint8List.fromList(utf8.encode(data));
|
|
|
|
// RSASSA-PKCS1-v1_5 + SHA1 검증
|
|
final signer = pc.Signer('SHA-1/RSA');
|
|
signer.init(
|
|
false, // verify 모드
|
|
pc.PublicKeyParameter<pc.RSAPublicKey>(publicKey),
|
|
);
|
|
|
|
return signer.verifySignature(
|
|
dataBytes,
|
|
pc.RSASignature(signatureBytes),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[IAPService] RSA verification error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// DER 인코딩된 공개키 파싱 (parse DER-encoded public key)
|
|
///
|
|
/// X.509 SubjectPublicKeyInfo 형식의 DER 바이트에서 RSA 공개키 추출.
|
|
pc.RSAPublicKey _parsePublicKeyFromDer(Uint8List bytes) {
|
|
// ASN.1 시퀀스 파서 (ASN.1 sequence parser)
|
|
final asn1Parser = ASN1Parser(bytes);
|
|
final topSequence = asn1Parser.nextObject() as ASN1Sequence;
|
|
|
|
// SubjectPublicKeyInfo 내부의 BitString에서 공개키 추출
|
|
final bitString = topSequence.elements![1] as ASN1BitString;
|
|
final publicKeyBytes = bitString.stringValues as Uint8List;
|
|
|
|
// RSA 공개키 시퀀스 파싱
|
|
final rsaParser = ASN1Parser(publicKeyBytes);
|
|
final rsaSequence = rsaParser.nextObject() as ASN1Sequence;
|
|
|
|
final modulus =
|
|
(rsaSequence.elements![0] as ASN1Integer).integer!;
|
|
final exponent =
|
|
(rsaSequence.elements![1] as ASN1Integer).integer!;
|
|
|
|
return pc.RSAPublicKey(modulus, exponent);
|
|
}
|
|
|
|
/// 구매 완료 처리 (필수)
|
|
Future<void> _completePurchase(PurchaseDetails purchase) async {
|
|
if (purchase.pendingCompletePurchase) {
|
|
await _iap.completePurchase(purchase);
|
|
debugPrint('[IAPService] Purchase completed: ${purchase.productID}');
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 정리
|
|
// ===========================================================================
|
|
|
|
/// 리소스 해제
|
|
@override
|
|
void dispose() {
|
|
_subscription?.cancel();
|
|
_subscription = null;
|
|
debugPrint('[IAPService] Disposed');
|
|
}
|
|
}
|