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 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(); // =========================================================================== // 상수 // =========================================================================== /// 구매 상태 저장 키 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>? _subscription; /// 광고 제거 구매 여부 (캐시) bool _adRemovalPurchased = false; /// 디버그 모드에서 IAP 시뮬레이션 활성화 여부 bool _debugIAPSimulated = false; // =========================================================================== // 초기화 // =========================================================================== /// IAP 서비스 초기화 @override Future 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 _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 _loadPurchaseState() async { final value = await _secureStorage.read(key: _purchaseKey); _adRemovalPurchased = value == 'true'; debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased'); } /// 구매 상태 저장 (보안 저장소 사용) Future _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 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 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 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 _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(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 _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'); } }