MotorSignal-App/lib/services/billing/play_billing_service.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();
}
}