2026-04-28 10:45:29 +08:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
|
|
|
|
|
|
|
|
import '../../../../core/logging/logger.dart';
|
|
|
|
|
import '../../../../core/network/api_problem.dart';
|
|
|
|
|
import '../../../../data/network/api_client.dart';
|
|
|
|
|
import '../../../points/data/models/package_info.dart';
|
|
|
|
|
import '../apis/apple_payment_api.dart';
|
|
|
|
|
import '../models/apple_purchase_models.dart';
|
|
|
|
|
|
|
|
|
|
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
|
|
|
|
|
|
|
|
|
|
class AppleIapService with ChangeNotifier {
|
2026-04-28 17:21:14 +08:00
|
|
|
AppleIapService({required ApiClient apiClient, required String userId})
|
|
|
|
|
: _paymentApi = ApplePaymentApi(apiClient: apiClient),
|
|
|
|
|
_inAppPurchase = InAppPurchase.instance,
|
|
|
|
|
_appAccountToken = _hashUserId(userId);
|
2026-04-28 10:45:29 +08:00
|
|
|
|
|
|
|
|
final ApplePaymentApi _paymentApi;
|
|
|
|
|
final InAppPurchase _inAppPurchase;
|
|
|
|
|
final String? _appAccountToken;
|
|
|
|
|
final Logger _logger = getLogger('features.payments.apple_iap_service');
|
|
|
|
|
|
|
|
|
|
static String? _hashUserId(String userId) {
|
|
|
|
|
if (userId.isEmpty) return null;
|
|
|
|
|
final bytes = utf8.encode(userId);
|
|
|
|
|
final digest = md5.convert(bytes);
|
|
|
|
|
return digest.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
|
|
|
|
Map<String, ProductDetails> _storeKitProducts = {};
|
|
|
|
|
PurchaseFlowState _state = PurchaseFlowState.idle;
|
|
|
|
|
String? _lastError;
|
|
|
|
|
ApiProblem? _lastApiProblem;
|
|
|
|
|
|
|
|
|
|
PurchaseFlowState get state => _state;
|
|
|
|
|
String? get lastError => _lastError;
|
|
|
|
|
ApiProblem? get lastApiProblem => _lastApiProblem;
|
|
|
|
|
|
|
|
|
|
void init() {
|
|
|
|
|
final Stream<List<PurchaseDetails>> purchaseUpdated;
|
|
|
|
|
purchaseUpdated = _inAppPurchase.purchaseStream;
|
|
|
|
|
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> loadStoreKitProducts(List<PackageInfo> packages) async {
|
|
|
|
|
final ids = packages
|
|
|
|
|
.map((p) => p.appStoreProductId)
|
|
|
|
|
.where((id) => id.isNotEmpty)
|
|
|
|
|
.toSet();
|
|
|
|
|
|
|
|
|
|
if (ids.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
final response = await _inAppPurchase.queryProductDetails(ids);
|
|
|
|
|
if (response.notFoundIDs.isNotEmpty) {
|
|
|
|
|
_logger.warning(
|
|
|
|
|
message: 'Some StoreKit products not found',
|
|
|
|
|
extra: {'notFound': response.notFoundIDs.join(', ')},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final products = <String, ProductDetails>{};
|
|
|
|
|
for (final detail in response.productDetails) {
|
|
|
|
|
products[detail.id] = detail;
|
|
|
|
|
}
|
|
|
|
|
_storeKitProducts = products;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ProductDetails? getStoreKitProduct(String appStoreProductId) {
|
|
|
|
|
return _storeKitProducts[appStoreProductId];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<bool> purchase(PackageInfo package) async {
|
|
|
|
|
if (_state == PurchaseFlowState.purchasing ||
|
|
|
|
|
_state == PurchaseFlowState.verifying) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final product = _storeKitProducts[package.appStoreProductId];
|
|
|
|
|
if (product == null) {
|
|
|
|
|
_logger.warning(
|
|
|
|
|
message: 'StoreKit product not found for purchase',
|
|
|
|
|
extra: {'productId': package.appStoreProductId},
|
|
|
|
|
);
|
|
|
|
|
_setError('Product not available');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_setState(PurchaseFlowState.purchasing);
|
|
|
|
|
|
|
|
|
|
final purchaseParam = PurchaseParam(
|
|
|
|
|
productDetails: product,
|
|
|
|
|
applicationUserName: _appAccountToken,
|
|
|
|
|
);
|
|
|
|
|
final bought = await _inAppPurchase.buyConsumable(
|
|
|
|
|
purchaseParam: purchaseParam,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!bought) {
|
2026-04-28 17:21:14 +08:00
|
|
|
_setState(PurchaseFlowState.idle);
|
2026-04-28 10:45:29 +08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
|
|
|
|
|
for (final purchase in purchases) {
|
|
|
|
|
_handlePurchase(purchase);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _handlePurchase(PurchaseDetails purchase) async {
|
2026-04-28 17:21:14 +08:00
|
|
|
try {
|
|
|
|
|
switch (purchase.status) {
|
|
|
|
|
case PurchaseStatus.purchased:
|
|
|
|
|
await _verifyAndComplete(purchase);
|
|
|
|
|
case PurchaseStatus.canceled:
|
|
|
|
|
await _completePurchaseSafely(purchase);
|
|
|
|
|
_setState(PurchaseFlowState.idle);
|
|
|
|
|
case PurchaseStatus.error:
|
|
|
|
|
final errorCode = purchase.error?.code;
|
|
|
|
|
final isUserCancel = errorCode == '2';
|
|
|
|
|
if (isUserCancel) {
|
|
|
|
|
await _completePurchaseSafely(purchase);
|
|
|
|
|
_setState(PurchaseFlowState.idle);
|
|
|
|
|
} else {
|
|
|
|
|
_logger.warning(
|
|
|
|
|
message: 'Purchase error',
|
|
|
|
|
extra: {
|
|
|
|
|
'errorCode': errorCode,
|
|
|
|
|
'errorMessage': purchase.error?.message,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await _completePurchaseSafely(purchase);
|
|
|
|
|
_setError(purchase.error?.message ?? 'Purchase failed');
|
|
|
|
|
}
|
|
|
|
|
case PurchaseStatus.pending:
|
|
|
|
|
_setState(PurchaseFlowState.purchasing);
|
|
|
|
|
case PurchaseStatus.restored:
|
|
|
|
|
await _completePurchaseSafely(purchase);
|
|
|
|
|
}
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
_logger.error(
|
|
|
|
|
message: 'Failed to handle purchase',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
extra: {
|
|
|
|
|
'productID': purchase.productID,
|
|
|
|
|
'purchaseID': purchase.purchaseID,
|
|
|
|
|
'status': purchase.status.name,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
_setState(PurchaseFlowState.idle);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _completePurchaseSafely(PurchaseDetails purchase) async {
|
|
|
|
|
try {
|
|
|
|
|
await _inAppPurchase.completePurchase(purchase);
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
_logger.warning(
|
|
|
|
|
message: 'completePurchase failed (likely null purchaseID): $e',
|
|
|
|
|
stackTrace: stackTrace,
|
|
|
|
|
extra: {
|
|
|
|
|
'productID': purchase.productID,
|
|
|
|
|
'purchaseID': purchase.purchaseID,
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-28 10:45:29 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _verifyAndComplete(PurchaseDetails purchase) async {
|
|
|
|
|
_setState(PurchaseFlowState.verifying);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final request = VerifyTransactionRequest(
|
|
|
|
|
productCode: _productCodeFromStoreKitId(purchase.productID),
|
|
|
|
|
appStoreProductId: purchase.productID,
|
|
|
|
|
transactionId: purchase.purchaseID ?? '',
|
|
|
|
|
signedTransactionInfo: purchase.verificationData.serverVerificationData,
|
|
|
|
|
appAccountToken: _appAccountToken,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final response = await _paymentApi.verifyTransaction(request);
|
|
|
|
|
await _inAppPurchase.completePurchase(purchase);
|
|
|
|
|
|
|
|
|
|
_state = PurchaseFlowState.success;
|
|
|
|
|
_lastError = null;
|
|
|
|
|
_lastApiProblem = null;
|
|
|
|
|
_logger.info(
|
|
|
|
|
message: 'Purchase verified and completed',
|
|
|
|
|
extra: {
|
|
|
|
|
'transactionId': purchase.purchaseID,
|
|
|
|
|
'creditsAdded': response.creditsAdded,
|
|
|
|
|
'status': response.status.name,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
notifyListeners();
|
|
|
|
|
} catch (e, stackTrace) {
|
|
|
|
|
_logger.error(
|
|
|
|
|
message: 'Purchase verification failed',
|
|
|
|
|
error: e,
|
|
|
|
|
stackTrace: stackTrace,
|
2026-04-28 17:21:14 +08:00
|
|
|
extra: {
|
|
|
|
|
'transactionId': purchase.purchaseID,
|
|
|
|
|
'productID': purchase.productID,
|
|
|
|
|
'errorType': e.runtimeType.toString(),
|
|
|
|
|
'errorMessage': e.toString(),
|
|
|
|
|
},
|
2026-04-28 10:45:29 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (_isRetryableError(e)) {
|
|
|
|
|
_setState(PurchaseFlowState.idle);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _inAppPurchase.completePurchase(purchase);
|
|
|
|
|
if (e is ApiProblem) {
|
|
|
|
|
_lastApiProblem = e;
|
|
|
|
|
_lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed';
|
|
|
|
|
} else {
|
|
|
|
|
_lastApiProblem = null;
|
|
|
|
|
_lastError = _extractErrorMessage(e);
|
|
|
|
|
}
|
|
|
|
|
_state = PurchaseFlowState.failed;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _isRetryableError(Object error) {
|
|
|
|
|
if (error is ApiProblem) {
|
|
|
|
|
return error.status >= 500 || error.status == 0;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _extractErrorMessage(Object error) {
|
|
|
|
|
if (error is ApiProblem) {
|
|
|
|
|
return error.detail.isNotEmpty ? error.detail : 'Verification failed';
|
|
|
|
|
}
|
|
|
|
|
return 'Verification failed';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _productCodeFromStoreKitId(String storeKitId) {
|
|
|
|
|
return switch (storeKitId) {
|
|
|
|
|
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
|
2026-04-28 17:21:14 +08:00
|
|
|
'com.meeyao.qianwen.starter_pack' => 'starter_pack',
|
2026-04-28 10:45:29 +08:00
|
|
|
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
|
|
|
|
|
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
|
|
|
|
|
_ => storeKitId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _setState(PurchaseFlowState state) {
|
|
|
|
|
_state = state;
|
|
|
|
|
if (state != PurchaseFlowState.failed) {
|
|
|
|
|
_lastError = null;
|
|
|
|
|
_lastApiProblem = null;
|
|
|
|
|
}
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _setError(String message) {
|
|
|
|
|
_state = PurchaseFlowState.failed;
|
|
|
|
|
_lastError = message;
|
|
|
|
|
_lastApiProblem = null;
|
|
|
|
|
_logger.warning(message: 'Purchase flow error', extra: {'error': message});
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void resetState() {
|
|
|
|
|
_state = PurchaseFlowState.idle;
|
|
|
|
|
_lastError = null;
|
|
|
|
|
_lastApiProblem = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_subscription?.cancel();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|