256 lines
7.5 KiB
Dart
256 lines
7.5 KiB
Dart
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<List<StorePurchase>> _purchaseStreamController =
|
|
StreamController<List<StorePurchase>>.broadcast();
|
|
|
|
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
|
final Set<String> _completedPurchaseTokens = <String>{};
|
|
Map<String, ProductDetails> _cachedProductDetails = {};
|
|
bool _initialized = false;
|
|
|
|
InAppPurchase get _instance => iap ?? InAppPurchase.instance;
|
|
|
|
PlayBillingService({this.iap});
|
|
|
|
@override
|
|
Stream<List<StorePurchase>> get purchaseStream =>
|
|
_purchaseStreamController.stream;
|
|
|
|
@override
|
|
Future<bool> 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<bool> isAvailable() async {
|
|
try {
|
|
return await _instance.isAvailable();
|
|
} catch (e) {
|
|
debugPrint('[PlayBillingService] isAvailable check failed: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<StoreProduct>> queryProducts() async {
|
|
try {
|
|
final response = await _instance.queryProductDetails(
|
|
ProductCatalog.allProductIds,
|
|
);
|
|
if (response.error != null) {
|
|
debugPrint(
|
|
'[PlayBillingService] Query products error: ${response.error}',
|
|
);
|
|
return [];
|
|
}
|
|
|
|
final results = <StoreProduct>[];
|
|
final newCache = <String, ProductDetails>{};
|
|
|
|
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<bool> 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<void> restorePurchases() async {
|
|
try {
|
|
await _instance.restorePurchases();
|
|
} catch (e, st) {
|
|
debugPrint('[PlayBillingService] restorePurchases failed: $e\n$st');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<PurchaseDetails> detailsList) {
|
|
final mapped = <StorePurchase>[];
|
|
|
|
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();
|
|
}
|
|
}
|