import 'dart:async'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'billing_models.dart'; import 'billing_service.dart'; import 'product_catalog.dart'; /// Production implementation of [BillingService] backed by the official `in_app_purchase` plugin. /// Wraps Google Play Billing 8.x via in_app_purchase_android. class PlayBillingService implements BillingService { final InAppPurchase? iap; final StreamController> _purchaseStreamController = StreamController>.broadcast(); StreamSubscription>? _subscription; final Set _completedPurchaseTokens = {}; Map _cachedProductDetails = {}; bool _initialized = false; InAppPurchase get _instance => iap ?? InAppPurchase.instance; PlayBillingService({this.iap}); @override Stream> get purchaseStream => _purchaseStreamController.stream; @override Future initialize() async { if (_initialized) return true; try { final available = await _instance.isAvailable(); if (!available) { debugPrint( '[PlayBillingService] InAppPurchase is not available on this device', ); return false; } // Establish singleton purchase stream listener _subscription ??= _instance.purchaseStream.listen( _handleIncomingPurchaseDetails, onError: (dynamic error) { debugPrint('[PlayBillingService] Purchase stream error: $error'); }, onDone: () { debugPrint('[PlayBillingService] Purchase stream completed'); }, ); _initialized = true; return true; } catch (e, st) { debugPrint('[PlayBillingService] Initialization failed: $e\n$st'); return false; } } @override Future isAvailable() async { try { return await _instance.isAvailable(); } catch (e) { debugPrint('[PlayBillingService] isAvailable check failed: $e'); return false; } } @override Future> queryProducts() async { try { final response = await _instance.queryProductDetails( ProductCatalog.allProductIds, ); if (response.error != null) { debugPrint( '[PlayBillingService] Query products error: ${response.error}', ); return []; } final results = []; final newCache = {}; for (final details in response.productDetails) { if (!ProductCatalog.isValidProduct(details.id)) continue; newCache[details.id] = details; results.add( StoreProduct( id: details.id, title: details.title, description: details.description, price: details.price, rawPrice: details.rawPrice, currencyCode: details.currencyCode, currencySymbol: details.currencySymbol, isSubscription: ProductCatalog.isSubscription(details.id), ), ); } _cachedProductDetails = newCache; return results; } catch (e, st) { debugPrint('[PlayBillingService] queryProducts failed: $e\n$st'); return []; } } @override Future launchPurchaseFlow(String productId) async { if (!ProductCatalog.isValidProduct(productId)) { debugPrint( '[PlayBillingService] Invalid productId requested: $productId', ); return false; } // Ensure product details are available var productDetails = _cachedProductDetails[productId]; if (productDetails == null) { await queryProducts(); productDetails = _cachedProductDetails[productId]; } if (productDetails == null) { debugPrint( '[PlayBillingService] Product details unavailable for $productId', ); return false; } try { final purchaseParam = PurchaseParam(productDetails: productDetails); if (ProductCatalog.isSubscription(productId)) { return await _instance.buyNonConsumable(purchaseParam: purchaseParam); } else { return await _instance.buyNonConsumable(purchaseParam: purchaseParam); } } catch (e, st) { debugPrint('[PlayBillingService] launchPurchaseFlow failed: $e\n$st'); return false; } } @override Future restorePurchases() async { try { await _instance.restorePurchases(); } catch (e, st) { debugPrint('[PlayBillingService] restorePurchases failed: $e\n$st'); } } @override Future completePurchase(StorePurchase purchase) async { final original = purchase.originalPayload; if (original is! PurchaseDetails) { debugPrint( '[PlayBillingService] completePurchase called with non-plugin payload', ); return; } final token = purchase.purchaseToken ?? purchase.purchaseId; if (_completedPurchaseTokens.contains(token)) { debugPrint( '[PlayBillingService] Purchase $token already completed/acknowledged, skipping duplicate', ); return; } try { if (original.pendingCompletePurchase) { await _instance.completePurchase(original); _completedPurchaseTokens.add(token); debugPrint( '[PlayBillingService] Completed purchase: ${purchase.productId}', ); } } catch (e, st) { debugPrint( '[PlayBillingService] completePurchase failed for ${purchase.productId}: $e\n$st', ); } } void _handleIncomingPurchaseDetails(List detailsList) { final mapped = []; for (final details in detailsList) { if (!ProductCatalog.isValidProduct(details.productID)) { debugPrint( '[PlayBillingService] Ignored unrecognized product: ${details.productID}', ); continue; } final token = details.verificationData.serverVerificationData; final tokenHash = token.isNotEmpty ? sha256.convert(token.codeUnits).toString() : null; StorePurchaseStatus status; switch (details.status) { case PurchaseStatus.pending: status = StorePurchaseStatus.pending; break; case PurchaseStatus.purchased: status = StorePurchaseStatus.purchased; break; case PurchaseStatus.restored: status = StorePurchaseStatus.restored; break; case PurchaseStatus.canceled: status = StorePurchaseStatus.canceled; break; case PurchaseStatus.error: status = StorePurchaseStatus.error; break; } mapped.add( StorePurchase( purchaseId: details.purchaseID ?? 'unknown_${DateTime.now().millisecondsSinceEpoch}', productId: details.productID, status: status, transactionDate: details.transactionDate ?? DateTime.now().toIso8601String(), purchaseToken: token.isNotEmpty ? token : null, purchaseTokenHash: tokenHash, pendingCompletePurchase: details.pendingCompletePurchase, errorMessage: details.error?.message, originalPayload: details, ), ); } if (!_purchaseStreamController.isClosed && mapped.isNotEmpty) { _purchaseStreamController.add(mapped); } } @override void dispose() { _subscription?.cancel(); _subscription = null; _purchaseStreamController.close(); } }