1479 lines
		
	
	
		
			60 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			1479 lines
		
	
	
		
			60 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:async';
 | ||
| import 'dart:collection';
 | ||
| import 'dart:convert';
 | ||
| import 'dart:io';
 | ||
| import 'dart:math';
 | ||
| 
 | ||
| import 'package:flutter/rendering.dart';
 | ||
| import 'package:guru_analytics_flutter/events_constants.dart';
 | ||
| import 'package:guru_app/account/account_data_store.dart';
 | ||
| import 'package:guru_app/api/data/orders/orders_model.dart';
 | ||
| import 'package:guru_app/api/guru_api.dart';
 | ||
| import 'package:guru_app/database/guru_db.dart';
 | ||
| import 'package:guru_app/financial/asset/assets_model.dart';
 | ||
| import 'package:guru_app/financial/asset/assets_store.dart';
 | ||
| import 'package:guru_app/financial/data/db/order_database.dart';
 | ||
| import 'package:guru_app/financial/iap/iap_model.dart';
 | ||
| import 'package:guru_app/financial/manifest/manifest.dart';
 | ||
| import 'package:guru_app/financial/manifest/manifest_manager.dart';
 | ||
| import 'package:guru_app/financial/product/product_model.dart';
 | ||
| import 'package:guru_app/financial/product/product_store.dart';
 | ||
| import 'package:guru_app/firebase/firebase.dart';
 | ||
| import 'package:guru_app/guru_app.dart';
 | ||
| import 'package:guru_app/property/app_property.dart';
 | ||
| import 'package:guru_app/property/property_keys.dart';
 | ||
| import 'package:guru_app/property/settings/guru_settings.dart';
 | ||
| import 'package:guru_utils/datetime/datetime_utils.dart';
 | ||
| import 'package:guru_utils/extensions/extensions.dart';
 | ||
| import 'package:guru_utils/math/math_utils.dart';
 | ||
| import 'package:guru_utils/tuple/tuple.dart';
 | ||
| import 'package:in_app_purchase/in_app_purchase.dart';
 | ||
| import 'package:in_app_purchase_android/billing_client_wrappers.dart';
 | ||
| import 'package:in_app_purchase_android/in_app_purchase_android.dart';
 | ||
| import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
 | ||
| import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';
 | ||
| 
 | ||
| /// Created by Haoyi on 2022/6/10
 | ||
| ///
 | ||
| 
 | ||
| enum IapCause { success, error, canceled }
 | ||
| 
 | ||
| class IapManager {
 | ||
|   static final IapManager _instance = IapManager._();
 | ||
| 
 | ||
|   static IapManager get instance => _instance;
 | ||
| 
 | ||
|   static final ProductDetailsResponse _emptyResponse =
 | ||
|       ProductDetailsResponse(productDetails: [], notFoundIDs: [], error: null);
 | ||
| 
 | ||
|   final BehaviorSubject<Map<ProductId, ProductDetails>> _productDetailsSubject =
 | ||
|       BehaviorSubject.seeded({});
 | ||
|   final BehaviorSubject<AssetsStore<Asset>> _iapStoreSubject =
 | ||
|       BehaviorSubject.seeded(AssetsStore<Asset>.inactive());
 | ||
| 
 | ||
|   final Map<ProductId, IapRequest> iapRequestMap = HashMap<ProductId, IapRequest>();
 | ||
| 
 | ||
|   Stream<Map<ProductId, ProductDetails>> get observableProductDetails =>
 | ||
|       _productDetailsSubject.stream;
 | ||
| 
 | ||
|   Stream<AssetsStore<Asset>> get observableAssetStore => _iapStoreSubject.stream;
 | ||
| 
 | ||
|   Map<ProductId, ProductDetails> get loadedProductDetails => _productDetailsSubject.value;
 | ||
| 
 | ||
|   AssetsStore<Asset> get purchasedStore => _iapStoreSubject.value;
 | ||
| 
 | ||
|   final BehaviorSubject<bool> availableSubject = BehaviorSubject.seeded(false);
 | ||
| 
 | ||
|   Stream<bool> get observableAvailable => availableSubject.stream;
 | ||
| 
 | ||
|   bool get iapAvailable => availableSubject.value;
 | ||
| 
 | ||
|   final InAppPurchase _inAppPurchase;
 | ||
| 
 | ||
|   StreamSubscription? subscription;
 | ||
| 
 | ||
|   Timer? restorePointsTimer;
 | ||
| 
 | ||
|   IapManager._() : _inAppPurchase = InAppPurchase.instance;
 | ||
| 
 | ||
|   IapCause latestIapCause = IapCause.success;
 | ||
| 
 | ||
|   bool _restorePurchase = false;
 | ||
| 
 | ||
|   final iapRevenueAppEventOptions = AppEventOptions(
 | ||
|       capabilities:
 | ||
|           const AppEventCapabilities(AppEventCapabilities.firebase | AppEventCapabilities.guru),
 | ||
|       firebaseParamsConvertor: _iapRevenueToValue,
 | ||
|       guruParamsConvertor: _iapRevenueToValue);
 | ||
| 
 | ||
|   static Map<String, dynamic> _iapRevenueToValue(Map<String, dynamic> params) {
 | ||
|     final result = Map.of(params);
 | ||
|     final revenue = result.remove("revenue");
 | ||
|     if (revenue != null) {
 | ||
|       result["value"] = revenue;
 | ||
|     }
 | ||
|     return result;
 | ||
|   }
 | ||
| 
 | ||
|   void init() async {
 | ||
|     final iapCount = await AppProperty.getInstance().getIapCount();
 | ||
|     if (iapCount > 0) {
 | ||
|       GuruAnalytics.instance.setUserProperty("purchase_count", iapCount.toString());
 | ||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "true");
 | ||
|     } else {
 | ||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "false");
 | ||
|     }
 | ||
|     try {
 | ||
|       await reloadOrders();
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("reloadOrders error! $error",
 | ||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true);
 | ||
|     }
 | ||
|     if (subscription == null) {
 | ||
|       final Stream<List<PurchaseDetails>> purchaseUpdated = _inAppPurchase.purchaseStream;
 | ||
|       subscription = purchaseUpdated.listen(
 | ||
|           (List<PurchaseDetails> purchaseDetailsList) {
 | ||
|             _listenToPurchaseUpdated(purchaseDetailsList);
 | ||
|           },
 | ||
|           onDone: () {},
 | ||
|           onError: (Object error) {
 | ||
|             // handle error here.
 | ||
|             Log.e("iap error:$error");
 | ||
|           });
 | ||
|       Log.i("iap service initialize completed");
 | ||
|     }
 | ||
|     Log.i("iap service initialized");
 | ||
| 
 | ||
|     _checkAndLoad();
 | ||
| 
 | ||
|     try {
 | ||
|       await AccountDataStore.instance.observableSaasUser
 | ||
|           .firstWhere((saasUser) => saasUser?.isValid == true);
 | ||
|       Future.delayed(const Duration(seconds: 5), () {
 | ||
|         reportFailedOrders();
 | ||
|       });
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("wait account error! $error", stackTrace: stacktrace);
 | ||
|     } finally {}
 | ||
|   }
 | ||
| 
 | ||
|   Future switchSession() async {
 | ||
|     final iapCount = await AppProperty.getInstance().getIapCount();
 | ||
|     if (iapCount > 0) {
 | ||
|       GuruAnalytics.instance.setUserProperty("purchase_count", iapCount.toString());
 | ||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "true");
 | ||
|     } else {
 | ||
|       GuruAnalytics.instance.setUserProperty("is_iap_user", "false");
 | ||
|     }
 | ||
|     try {
 | ||
|       await reloadOrders();
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("reloadOrders error! $error",
 | ||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true);
 | ||
|     }
 | ||
|     _checkAndLoad();
 | ||
|   }
 | ||
| 
 | ||
|   Future reloadOrders() async {
 | ||
|     final transactions = await GuruDB.instance.selectOrders(
 | ||
|         method: TransactionMethod.iap,
 | ||
|         attrs: [TransactionAttributes.asset, TransactionAttributes.subscriptions]);
 | ||
|     final newAssetStore = AssetsStore<Asset>();
 | ||
|     Log.d("reloadOrders ${transactions.length}");
 | ||
|     for (var transaction in transactions) {
 | ||
|       final productId = transaction.productId;
 | ||
|       Log.d(" ==> reloadOrder:${transaction.sku} $productId");
 | ||
|       newAssetStore.addAsset(Asset(productId, transaction));
 | ||
|     }
 | ||
|     _iapStoreSubject.addEx(newAssetStore);
 | ||
|   }
 | ||
| 
 | ||
|   void _checkAndLoad() async {
 | ||
|     var available = false;
 | ||
|     var retry = 0;
 | ||
|     Log.i("_checkAndLoad");
 | ||
|     do {
 | ||
|       final seconds = min(MathUtils.fibonacci(retry, offset: 2), 900);
 | ||
|       await Future.delayed(Duration(seconds: seconds));
 | ||
|       available = await _inAppPurchase.isAvailable().catchError((error, stacktrace) {
 | ||
|         Log.w("isAvailable error:$error", stackTrace: stacktrace);
 | ||
|         return false;
 | ||
|       });
 | ||
|       Log.d("_checkAndLoad:$retry available:$available");
 | ||
|       retry++;
 | ||
|     } while (!available);
 | ||
|     availableSubject.addIfChanged(true);
 | ||
|     try {
 | ||
|       await refreshProducts();
 | ||
|       if (GuruApp.instance.appSpec.deployment.autoRestoreIap ||
 | ||
|           GuruApp.instance.appSpec.productProfile.hasSubs()) {
 | ||
|         await restorePurchases();
 | ||
|       }
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("restorePurchases error:$error",
 | ||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future<bool> isAvailable() async {
 | ||
|     return await _inAppPurchase.isAvailable();
 | ||
|   }
 | ||
| 
 | ||
|   void _processIapError() async {
 | ||
|     latestIapCause = IapCause.error;
 | ||
|     for (var iapRequest in iapRequestMap.values) {
 | ||
|       iapRequest.response(false);
 | ||
|       final iapErrorMsg = "_processIapError:${iapRequest.productId}";
 | ||
|       Log.w(iapErrorMsg,
 | ||
|           error: PurchaseError(iapErrorMsg), syncFirebase: true, syncCrashlytics: true);
 | ||
|       try {
 | ||
|         await GuruDB.instance.upsertOrder(order: iapRequest.order.error(iapErrorMsg));
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("_processIapError upsert error! $error", syncFirebase: true);
 | ||
|       }
 | ||
|     }
 | ||
|     iapRequestMap.clear();
 | ||
|   }
 | ||
| 
 | ||
|   void _processIapCancel() async {
 | ||
|     latestIapCause = IapCause.canceled;
 | ||
|     Log.d("_processIapCancel");
 | ||
|     for (var iapRequest in iapRequestMap.values) {
 | ||
|       final order = iapRequest.order;
 | ||
|       iapRequest.response(false);
 | ||
|       try {
 | ||
|         await GuruDB.instance.deleteOrder(order: order);
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("_processIapCancel deleteOrder error! $error", syncFirebase: true);
 | ||
|       }
 | ||
|     }
 | ||
|     iapRequestMap.clear();
 | ||
|     // final iapErrorMsg = "_processIapCancel:$productId";
 | ||
|     // Log.w(iapErrorMsg,
 | ||
|     //     error: PurchaseError(iapErrorMsg), syncFirebase: true, syncCrashlytics: true);
 | ||
|   }
 | ||
| 
 | ||
|   // void _listenToPurchased() async {
 | ||
|   //   InAppPurchase.instance.purchaseStream.listen((purchaseDetailsList) {
 | ||
|   //     if (purchaseDetailsList.isEmpty) {
 | ||
|   //       return;
 | ||
|   //     }
 | ||
|   //     final subscriptionDetails = <ProductId, PurchaseDetails>{};
 | ||
|   //     for (var details in purchaseDetailsList) {
 | ||
|   //       Log.d(" details:${details.productID} ${details.status}");
 | ||
|   //       final productId =
 | ||
|   //           GuruApp.instance.findProductId(sku: details.productID) ?? ProductId.invalid;
 | ||
|   //       if (productId.isSubscription) {
 | ||
|   //         subscriptionDetails[productId] = details;
 | ||
|   //       }
 | ||
|   //     }
 | ||
|   //     if (Platform.isIOS) {
 | ||
|   //       checkSubscriptionForIos(subscriptionDetails);
 | ||
|   //     }
 | ||
|   //   });
 | ||
|   // }
 | ||
| 
 | ||
|   String dumpProductAndPurchased(ProductDetails details, PurchaseDetails purchaseDetails) {
 | ||
|     final StringBuffer sb = StringBuffer();
 | ||
| 
 | ||
|     if (Platform.isAndroid) {
 | ||
|       try {
 | ||
|         GooglePlayPurchaseDetails googlePlayDetails = purchaseDetails as GooglePlayPurchaseDetails;
 | ||
|         GooglePlayProductDetails googlePlayProduct = details as GooglePlayProductDetails;
 | ||
|         Log.d(
 | ||
|             "Android Product/Purchase ${buildGooglePlayDetailsString(googlePlayProduct, googlePlayDetails)}");
 | ||
|       } catch (error, stacktrace) {}
 | ||
|     } else if (Platform.isIOS) {
 | ||
|       AppStorePurchaseDetails appleDetails = purchaseDetails as AppStorePurchaseDetails;
 | ||
|       AppStoreProductDetails appleProduct = details as AppStoreProductDetails;
 | ||
|       sb.writeln("#### purchase ####");
 | ||
|       sb.writeln("productID: ${appleDetails.productID}");
 | ||
|       sb.writeln("purchaseID: ${appleDetails.purchaseID}");
 | ||
|       sb.writeln("transactionDate: ${appleDetails.transactionDate}");
 | ||
|       sb.writeln("verificationData: ${appleDetails.verificationData}");
 | ||
|       sb.writeln("status: ${appleDetails.status}");
 | ||
|       sb.writeln("skPaymentTransaction:");
 | ||
|       sb.writeln(
 | ||
|           "   =>originalTransaction:${appleDetails.skPaymentTransaction.originalTransaction}");
 | ||
|       sb.writeln("       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}");
 | ||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionState}:");
 | ||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:");
 | ||
|       sb.writeln("\n#### product ####");
 | ||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}");
 | ||
|       sb.writeln("rawPrice: ${appleProduct.rawPrice}");
 | ||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}");
 | ||
|       sb.writeln("currencyCode skProduct");
 | ||
|       sb.writeln("    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}");
 | ||
|       sb.writeln("    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}");
 | ||
|       sb.writeln("    =>priceLocale: ${appleProduct.skProduct.priceLocale}");
 | ||
|       sb.writeln("    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}");
 | ||
|       sb.writeln(
 | ||
|           "    =>subscriptionGroupIdentifier: ${appleProduct.skProduct.subscriptionGroupIdentifier}");
 | ||
|       sb.writeln("    =>appleProduct.skProduct.priceLocale");
 | ||
|       sb.writeln("        ->{appleProduct.skProduct.priceLocale}");
 | ||
| 
 | ||
|       Log.d("IOS Product/Purchase ${sb.toString()}");
 | ||
|     }
 | ||
|     return sb.toString();
 | ||
|   }
 | ||
| 
 | ||
|   static final monthRenewalDurations = [
 | ||
|     3 * DateTimeUtils.minuteInMillis,
 | ||
|     5 * DateTimeUtils.minuteInMillis,
 | ||
|     15 * DateTimeUtils.minuteInMillis,
 | ||
|     30 * DateTimeUtils.minuteInMillis,
 | ||
|     DateTimeUtils.hourInMillis
 | ||
|   ];
 | ||
| 
 | ||
|   static final weekRenewalDurations = [
 | ||
|     3 * DateTimeUtils.minuteInMillis,
 | ||
|     3 * DateTimeUtils.minuteInMillis,
 | ||
|     5 * DateTimeUtils.minuteInMillis,
 | ||
|     10 * DateTimeUtils.minuteInMillis,
 | ||
|     15 * DateTimeUtils.minuteInMillis
 | ||
|   ];
 | ||
| 
 | ||
|   int getIOSPeriodInterval(int numberOfUnits, SKSubscriptionPeriodUnit unit) {
 | ||
|     if (GuruSettings.instance.debugMode.get()) {
 | ||
|       final renewalSpeed =
 | ||
|           GuruApp.instance.appSpec.deployment.iosSandboxSubsRenewalSpeed.clamp(1, 5);
 | ||
|       switch (unit) {
 | ||
|         case SKSubscriptionPeriodUnit.day:
 | ||
|           return numberOfUnits * weekRenewalDurations[renewalSpeed - 1] ~/ 7;
 | ||
|         case SKSubscriptionPeriodUnit.week:
 | ||
|           return numberOfUnits * weekRenewalDurations[renewalSpeed - 1];
 | ||
|         case SKSubscriptionPeriodUnit.month:
 | ||
|           return numberOfUnits * monthRenewalDurations[renewalSpeed - 1];
 | ||
|         case SKSubscriptionPeriodUnit.year:
 | ||
|           return numberOfUnits * monthRenewalDurations[renewalSpeed - 1] * 12;
 | ||
|       }
 | ||
|     } else {
 | ||
|       switch (unit) {
 | ||
|         case SKSubscriptionPeriodUnit.day:
 | ||
|           return numberOfUnits * DateTimeUtils.dayInMillis;
 | ||
|         case SKSubscriptionPeriodUnit.week:
 | ||
|           return numberOfUnits * DateTimeUtils.weekInMillis;
 | ||
|         case SKSubscriptionPeriodUnit.month:
 | ||
|           return numberOfUnits * DateTimeUtils.dayInMillis * 31;
 | ||
|         case SKSubscriptionPeriodUnit.year:
 | ||
|           return numberOfUnits * DateTimeUtils.dayInMillis * 366;
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   bool checkSubscriptionPeriod(PurchaseDetails purchaseDetails, ProductDetails productDetails) {
 | ||
|     bool validOrder = false;
 | ||
|     if (Platform.isAndroid) {
 | ||
|       validOrder = true;
 | ||
|     } else if (Platform.isIOS) {
 | ||
|       final appleProduct = productDetails as AppStoreProductDetails;
 | ||
|       SKProductSubscriptionPeriodWrapper? period = appleProduct.skProduct.subscriptionPeriod;
 | ||
| 
 | ||
|       final appleDetails = purchaseDetails as AppStorePurchaseDetails;
 | ||
|       final paymentDiscount = appleDetails.skPaymentTransaction.payment.paymentDiscount;
 | ||
|       if (paymentDiscount != null) {
 | ||
|         Log.i("paymentDiscount: ${paymentDiscount.identifier} ${paymentDiscount.timestamp}");
 | ||
|         for (var discount in appleProduct.skProduct.discounts) {
 | ||
|           final discountSubPeriod = discount.subscriptionPeriod;
 | ||
|           Log.i(
 | ||
|               "check discount(${paymentDiscount.identifier}) product [ ${discount.identifier}  ${discountSubPeriod.unit} ${discountSubPeriod.numberOfUnits} ]");
 | ||
|           if (discount.identifier == paymentDiscount.identifier) {
 | ||
|             period = discount.subscriptionPeriod;
 | ||
|             break;
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
| 
 | ||
|       Log.i("checkSubscriptionPeriod: ${appleProduct.skProduct.productIdentifier} ${period?.unit} ${period?.numberOfUnits} ${appleDetails.transactionDate}");
 | ||
| 
 | ||
|       if (period != null) {
 | ||
|         final numberOfUnits = period.numberOfUnits;
 | ||
|         final unit = period.unit;
 | ||
|         final int validInterval = getIOSPeriodInterval(numberOfUnits, unit);
 | ||
|         final transactionTs = int.tryParse(purchaseDetails.transactionDate ?? "") ?? 0;
 | ||
|         final now = DateTimeUtils.currentTimeInMillis();
 | ||
|         final gracePeriod = GuruApp.instance.remoteDeployment.subscriptionGraceDays ??
 | ||
|             GuruApp.instance.appSpec.deployment.subscriptionGraceDays;
 | ||
|         /// 过期时间 = 订单的最后一次刷新时间 + 订阅周期(优惠周期) + 宽限期
 | ||
|         final expiredTs = transactionTs + validInterval + gracePeriod;
 | ||
|         /// 如果当前的时间小于过期时间,那么这个订单是有效的
 | ||
|         validOrder = now < expiredTs;
 | ||
|         Log.d(
 | ||
|             "productID: ${purchaseDetails.productID}) purchaseID: ${purchaseDetails.purchaseID}[$numberOfUnits][$unit] [$now < $transactionTs + $validInterval]($validOrder) ",
 | ||
|             tag: PropertyTags.iap);
 | ||
|       }
 | ||
|     }
 | ||
|     return validOrder;
 | ||
|   }
 | ||
| 
 | ||
|   Future processRestoredSubscription(List<PurchaseDetails> subscriptionPurchased) async {
 | ||
|     List<PurchaseDetails> purchasedDetails = subscriptionPurchased;
 | ||
|     if (Platform.isIOS) {
 | ||
|       purchasedDetails = buildLatestPurchasedPlanForIos(subscriptionPurchased);
 | ||
|     }
 | ||
|     final newPurchasedStore = purchasedStore.clone();
 | ||
|     final expiredSkus = <String>{};
 | ||
|     // 由于Android的订阅项目在失效后,这里将不会返回,因此需要判断这里的newPurchasedStore是否存在对应的purchased
 | ||
|     // 如果存在将会在后面进行处理,如果不存在。这里将会从purchasedStore中删除
 | ||
|     if (Platform.isAndroid) {
 | ||
|       final purchasedSkus = purchasedDetails.map((e) => e.productID).toSet();
 | ||
|       newPurchasedStore.removeWhere((productId, asset) {
 | ||
|         final expired = productId.isSubscription && !purchasedSkus.contains(productId.sku);
 | ||
|         Log.i("remove expired subscription[$productId] expired:$expired");
 | ||
|         if (expired) {
 | ||
|           expiredSkus.add(asset.productId.sku);
 | ||
|         }
 | ||
|         return expired;
 | ||
|       });
 | ||
|     }
 | ||
| 
 | ||
|     for (var purchased in purchasedDetails) {
 | ||
|       final productId = GuruApp.instance.findProductId(sku: purchased.productID);
 | ||
|       if (productId == null) {
 | ||
|         Log.w("productId is null! ${purchased.productID}");
 | ||
|         continue;
 | ||
|       }
 | ||
|       final productDetails = loadedProductDetails[productId];
 | ||
|       if (productDetails == null) {
 | ||
|         Log.w("product is null! ${purchased.productID}");
 | ||
|         continue;
 | ||
|       }
 | ||
|       final bool validPurchase = checkSubscriptionPeriod(purchased, productDetails);
 | ||
|       if (validPurchase) {
 | ||
|         Log.d(
 | ||
|             "[Restored Subscription] productID: ${purchased.productID}) purchaseID: ${purchased.purchaseID}",
 | ||
|             tag: PropertyTags.iap);
 | ||
|         final asset = newPurchasedStore.getAsset(productId);
 | ||
|         late OrderEntity newOrder;
 | ||
|         if (asset == null) {
 | ||
|           final product =
 | ||
|               await _createProduct(productId.createIntent(scene: "restore"), productDetails);
 | ||
|           newOrder = product.createOrder().success();
 | ||
|         } else {
 | ||
|           newOrder = asset.order.success();
 | ||
|         }
 | ||
|         try {
 | ||
|           await GuruDB.instance.replaceOrderBySku(order: newOrder);
 | ||
|         } catch (error, stacktrace) {
 | ||
|           Log.w("Failed to upsert order: $error $stacktrace", tag: PropertyTags.iap);
 | ||
|         }
 | ||
|         final newAsset = Asset(productId, newOrder);
 | ||
|         newPurchasedStore.addAsset(newAsset);
 | ||
|       } else {
 | ||
|         expiredSkus.add(productId.sku);
 | ||
|         Log.d(
 | ||
|             "Subscription is expired ${purchased.productID}) ${purchased.purchaseID} ${purchased.transactionDate}");
 | ||
|         // 这里暂不清newPurchasedStore,下次重进后该订阅信息会失效
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (expiredSkus.isNotEmpty) {
 | ||
|       final graceCount = await AppProperty.getInstance().increaseGraceCount();
 | ||
|       Log.i("expired orders:${expiredSkus.length}} grace count: $graceCount");
 | ||
|       if (graceCount > GuruApp.instance.appSpec.deployment.subscriptionRestoreGraceCount) {
 | ||
|         try {
 | ||
|           await GuruDB.instance.deleteOrdersBySkus(expiredSkus);
 | ||
|         } catch (error, stacktrace) {
 | ||
|           Log.w("Failed to upsert order: $error $stacktrace", tag: PropertyTags.iap);
 | ||
|         }
 | ||
|         await AppProperty.getInstance().resetGraceCount();
 | ||
|         GuruAnalytics.instance.logGuruEvent('dev_iap_audit', {
 | ||
|           "item_category": "expired",
 | ||
|           "item_name": "sub",
 | ||
|           "platform": Platform.isAndroid ? "Android" : "iOS",
 | ||
|           "value": graceCount
 | ||
|         });
 | ||
|       }
 | ||
|     } else {
 | ||
|       await AppProperty.getInstance().resetGraceCount();
 | ||
|     }
 | ||
|     _iapStoreSubject.addEx(newPurchasedStore);
 | ||
|     Log.d("[RestoredSubscription] update purchasedStore ${newPurchasedStore.data.length}");
 | ||
|   }
 | ||
| 
 | ||
|   List<PurchaseDetails> buildLatestPurchasedPlanForIos(List<PurchaseDetails> purchaseDetails) {
 | ||
|     if (purchaseDetails.isEmpty) {
 | ||
|       return [];
 | ||
|     }
 | ||
|     final rawTransactionIds = purchaseDetails
 | ||
|         .map((details) => (details as AppStorePurchaseDetails)
 | ||
|             .skPaymentTransaction
 | ||
|             .originalTransaction
 | ||
|             ?.transactionIdentifier)
 | ||
|         .where((element) => element != null)
 | ||
|         .cast<String>()
 | ||
|         .toSet();
 | ||
|     Log.d("rawTransactionIds:$rawTransactionIds");
 | ||
|     final sortedPurchaseDetails = purchaseDetails.toList();
 | ||
|     sortedPurchaseDetails.sort((a, b) => (int.tryParse(b.transactionDate ?? '') ?? 0)
 | ||
|         .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0));
 | ||
|     sortedPurchaseDetails.retainWhere((details) {
 | ||
|       var detail = details as AppStorePurchaseDetails;
 | ||
|       Log.d(
 | ||
|           "checkSubscriptionForIos ${detail.skPaymentTransaction.originalTransaction?.transactionIdentifier} ${detail.transactionDate} ${detail.skPaymentTransaction.transactionTimeStamp}");
 | ||
|       return rawTransactionIds
 | ||
|           .remove(detail.skPaymentTransaction.originalTransaction?.transactionIdentifier);
 | ||
|     });
 | ||
| 
 | ||
|     return sortedPurchaseDetails;
 | ||
|   }
 | ||
| 
 | ||
|   void checkSubscriptionForIos(List<PurchaseDetails> purchaseDetails) {
 | ||
|     if (purchaseDetails.isEmpty) {
 | ||
|       return;
 | ||
|     }
 | ||
|     final rawTransactionIds = purchaseDetails
 | ||
|         .map((details) => (details as AppStorePurchaseDetails)
 | ||
|             .skPaymentTransaction
 | ||
|             .originalTransaction
 | ||
|             ?.transactionIdentifier)
 | ||
|         .where((element) => element != null)
 | ||
|         .cast<String>()
 | ||
|         .toSet();
 | ||
|     Log.d("rawTransactionIds:$rawTransactionIds");
 | ||
|     final sortedPurchaseDetails = purchaseDetails.toList();
 | ||
|     sortedPurchaseDetails.sort((a, b) => (int.tryParse(b.transactionDate ?? '') ?? 0)
 | ||
|         .compareTo(int.tryParse(a.transactionDate ?? '') ?? 0));
 | ||
|     sortedPurchaseDetails.retainWhere((details) {
 | ||
|       var detail = details as AppStorePurchaseDetails;
 | ||
|       Log.d(
 | ||
|           "checkSubscriptionForIos ${detail.skPaymentTransaction.originalTransaction?.transactionIdentifier} ${detail.transactionDate} ${detail.skPaymentTransaction.transactionTimeStamp}");
 | ||
|       return rawTransactionIds
 | ||
|           .remove(detail.skPaymentTransaction.originalTransaction?.transactionIdentifier);
 | ||
|     });
 | ||
| 
 | ||
|     for (var details in sortedPurchaseDetails) {
 | ||
|       Log.d(
 | ||
|           "checkSubscriptionForIos ${details.productID} ${details.status} ${details.transactionDate}");
 | ||
|       final productId = GuruApp.instance.findProductId(sku: details.productID);
 | ||
|       final productDetails = loadedProductDetails[productId];
 | ||
|       if (productDetails != null) {
 | ||
|         dumpProductAndPurchased(productDetails, details);
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
 | ||
|     final List<Tuple2<ProductId, PurchaseDetails>> restoredIapPurchases = [];
 | ||
|     final List<Tuple2<ProductId, PurchaseDetails>> pendingCompletePurchase = [];
 | ||
|     final List<PurchaseDetails> subscriptionPurchases = [];
 | ||
|     bool existsRestored = false;
 | ||
|     bool needRestore = false;
 | ||
|     Log.d("_listenToPurchaseUpdated ${purchaseDetailsList.length}");
 | ||
|     if (purchaseDetailsList.isEmpty) {
 | ||
|       if (_restorePurchase) {
 | ||
|         try {
 | ||
|           await processRestoredSubscription(subscriptionPurchases);
 | ||
|         } catch (error, stacktrace) {
 | ||
|           Log.w(
 | ||
|               "purchaseDetailsList is EMPTY! processRestoredSubscription error! $error $stacktrace",
 | ||
|               syncFirebase: true,
 | ||
|               syncCrashlytics: true);
 | ||
|         }
 | ||
|         _restorePurchase = false;
 | ||
|       }
 | ||
|       return;
 | ||
|     }
 | ||
|     for (var details in purchaseDetailsList) {
 | ||
|       final productId = GuruApp.instance.findProductId(sku: details.productID) ?? ProductId.invalid;
 | ||
|       Log.d(
 | ||
|           "[details]: $productId [${details.productID}] ${details.purchaseID} ${details.status} ${details.pendingCompletePurchase}");
 | ||
|       GuruAnalytics.instance.logGuruEvent('dev_iap_update', {
 | ||
|         "sku": details.productID,
 | ||
|         "orderId": details.purchaseID,
 | ||
|         "status": "${details.status.index}"
 | ||
|       });
 | ||
|       switch (details.status) {
 | ||
|         case PurchaseStatus.purchased:
 | ||
|           if (details.productID == "") {
 | ||
|             if (GuruApp.instance.productProfile.pointsIapIds.isNotEmpty) {
 | ||
|               Log.w(
 | ||
|                   "details.productID is empty And Exists PointsIap! ${details.purchaseID}! need restore");
 | ||
|               needRestore = true;
 | ||
|             } else {
 | ||
|               Log.w("details.productID is empty ${details.purchaseID}! ignore!!");
 | ||
|             }
 | ||
|             continue;
 | ||
|           }
 | ||
| 
 | ||
|           final productDetails = loadedProductDetails[productId];
 | ||
|           if (productDetails != null) {
 | ||
|             /// 如果是 IOS的 purchased订单,并且是订阅的订单,他又没在当前请求的列表中,证明他是一个恢复的订单
 | ||
|             if (Platform.isIOS && productId.isSubscription && !iapRequestMap.containsKey(productId)) {
 | ||
|               subscriptionPurchases.add(details);
 | ||
|               existsRestored = true;
 | ||
|             } else {
 | ||
|               await _completePurchase(productId, productDetails, details);
 | ||
|             }
 | ||
|           }
 | ||
| 
 | ||
|           Log.d("completePurchase ${details.productID} ${details.purchaseID}");
 | ||
|           break;
 | ||
|         case PurchaseStatus.restored:
 | ||
|           _restorePurchase = false;
 | ||
|           existsRestored = true;
 | ||
|           if (productId.isAsset) {
 | ||
|             restoredIapPurchases.add(Tuple2(productId, details));
 | ||
|             Log.d("restore possessive iap:$productId");
 | ||
|           } else if (productId.isSubscription) {
 | ||
|             Log.w("restore subscription product!", syncFirebase: true);
 | ||
|             subscriptionPurchases.add(details);
 | ||
|           }
 | ||
|           // 如果是未完成的商品或是恢复出了消耗品,都需要手动完成
 | ||
|           if (Platform.isAndroid) {
 | ||
|             final originPurchaseState =
 | ||
|                 (details as GooglePlayPurchaseDetails).billingClientPurchase.purchaseState;
 | ||
|             Log.d(
 | ||
|                 "restore android ${details.pendingCompletePurchase} $productId $originPurchaseState");
 | ||
|             if (originPurchaseState == PurchaseStateWrapper.purchased) {
 | ||
|               if (productId.isConsumable ||
 | ||
|                   (details.pendingCompletePurchase && productId.isAsset)) {
 | ||
|                 Log.w("restore consumable product!", syncFirebase: true);
 | ||
|                 pendingCompletePurchase.add(Tuple2(productId, details));
 | ||
|               }
 | ||
|             }
 | ||
|           } else {
 | ||
|             if (details.pendingCompletePurchase) {
 | ||
|               Log.d("restore ios pendingCompletePurchase: $productId");
 | ||
|               await _inAppPurchase.completePurchase(details);
 | ||
|             }
 | ||
|           }
 | ||
|           break;
 | ||
|         case PurchaseStatus.error:
 | ||
|           _processIapError();
 | ||
|           if (details.pendingCompletePurchase) {
 | ||
|             await _inAppPurchase.completePurchase(details);
 | ||
|           }
 | ||
|           break;
 | ||
|         case PurchaseStatus.canceled:
 | ||
|           _processIapCancel();
 | ||
|           if (details.pendingCompletePurchase) {
 | ||
|             await _inAppPurchase.completePurchase(details);
 | ||
|           }
 | ||
|           break;
 | ||
|         default:
 | ||
|           break;
 | ||
|       }
 | ||
|       Log.d(
 | ||
|           "_listenToPurchaseUpdated2:$productId [${details.productID}] ${details.purchaseID} ${details.status} ${details.pendingCompletePurchase}");
 | ||
|     }
 | ||
|     if (existsRestored) {
 | ||
|       if (pendingCompletePurchase.isNotEmpty) {
 | ||
|         await completeAllPurchases(pendingCompletePurchase);
 | ||
|         Log.d("manual complete/consume all purchases!", syncFirebase: true, syncCrashlytics: true);
 | ||
|       }
 | ||
| 
 | ||
|       if (restoredIapPurchases.isNotEmpty) {
 | ||
|         try {
 | ||
|           await processRestoredPurchases(restoredIapPurchases);
 | ||
|         } catch (error, stacktrace) {
 | ||
|           Log.w("processRestoredPurchases error! $error $stacktrace",
 | ||
|               syncFirebase: true, syncCrashlytics: true);
 | ||
|         }
 | ||
|       }
 | ||
|       try {
 | ||
|         await processRestoredSubscription(subscriptionPurchases);
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("processRestoredSubscription error! $error $stacktrace",
 | ||
|             syncFirebase: true, syncCrashlytics: true);
 | ||
|       }
 | ||
|     }
 | ||
|     if (needRestore) {
 | ||
|       restorePointsTimer?.cancel();
 | ||
|       restorePointsTimer = Timer(const Duration(seconds: 1), () {
 | ||
|         restorePurchases();
 | ||
|       });
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future processRestoredPurchases(
 | ||
|       List<Tuple2<ProductId, PurchaseDetails>> restoredIapPurchases) async {
 | ||
|     final newPurchased = purchasedStore.clone();
 | ||
|     final currentLoadedProductDetails = loadedProductDetails;
 | ||
|     final upsertOrders = <OrderEntity>[];
 | ||
|     for (var iapPurchased in restoredIapPurchases) {
 | ||
|       final productId = iapPurchased.item1;
 | ||
|       final asset = newPurchased.getAsset(iapPurchased.item1);
 | ||
|       final productDetails = currentLoadedProductDetails[productId];
 | ||
|       final order = asset?.order;
 | ||
|       // 证明是已经购买过的
 | ||
|       if (order != null) {
 | ||
|         // 如果没有购买成功,那么就重新创建一个
 | ||
|         if (!order.isSuccess) {
 | ||
|           final newOrder = order.success();
 | ||
|           upsertOrders.add(newOrder);
 | ||
|         }
 | ||
|       } else if (productDetails != null) {
 | ||
|         final product =
 | ||
|             await _createProduct(productId.createIntent(scene: "restore"), productDetails);
 | ||
|         final newOrder = product.createOrder().success();
 | ||
|         upsertOrders.add(newOrder);
 | ||
|       }
 | ||
|     }
 | ||
|     if (upsertOrders.isNotEmpty) {
 | ||
|       final List<OrderEntity> updatedOrder = [];
 | ||
|       try {
 | ||
|         await GuruDB.instance.upsertOrders(upsertOrders);
 | ||
|         updatedOrder.addAll(upsertOrders);
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("upsertOrders error:$error $stacktrace", syncCrashlytics: true, syncFirebase: true);
 | ||
|         for (var order in upsertOrders) {
 | ||
|           try {
 | ||
|             await GuruDB.instance.upsertOrder(order: order);
 | ||
|             updatedOrder.add(order);
 | ||
|           } catch (error1, stacktrace1) {
 | ||
|             Log.w("upsertOrder(${order.sku}) error:$error1 $stacktrace1", syncFirebase: true);
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|       final assets = updatedOrder.map((order) => Asset(order.productId, order)).toList();
 | ||
|       newPurchased.addAllAssets(assets);
 | ||
|     }
 | ||
|     _iapStoreSubject.addEx(newPurchased);
 | ||
|     Log.d("[RestoredPurchases] update purchasedStore ${upsertOrders.length}");
 | ||
|   }
 | ||
| 
 | ||
|   Future reportFailedOrders() async {
 | ||
|     final failedIapOrders = await AppProperty.getInstance().loadAllFailedIapOrders();
 | ||
|     failedIapOrders.forEach((key, value) async {
 | ||
|       try {
 | ||
|         final order = OrdersReport.fromJson(json.decode(value));
 | ||
|         final result = await GuruApi.instance.reportOrders(order);
 | ||
|         // 这里不管返回什么值,都认为是成功的, 只有崩溃才会返回失败
 | ||
|         await logRevenue(order, result);
 | ||
|         AppProperty.getInstance().removeReportSuccessOrder(key);
 | ||
|       } catch (error, stacktrace) {}
 | ||
|     });
 | ||
|     Log.i("reportFailedOrders success!");
 | ||
|   }
 | ||
| 
 | ||
|   String buildGooglePlayDetailsString(
 | ||
|       GooglePlayProductDetails googlePlayProduct, GooglePlayPurchaseDetails googlePlayDetails) {
 | ||
|     final StringBuffer sb = StringBuffer();
 | ||
|     sb.writeln("#### purchase ####");
 | ||
| 
 | ||
|     sb.writeln("productID: ${googlePlayDetails.productID}");
 | ||
|     sb.writeln("purchaseID: ${googlePlayDetails.purchaseID}");
 | ||
|     sb.writeln("transactionDate: ${googlePlayDetails.transactionDate}");
 | ||
| 
 | ||
|     sb.writeln("status: ${googlePlayDetails.status}");
 | ||
|     sb.writeln("verificationData:");
 | ||
|     sb.writeln(
 | ||
|         "  => localVerificationData: ${googlePlayDetails.verificationData.localVerificationData}");
 | ||
|     sb.writeln(
 | ||
|         "  => serverVerificationData: ${googlePlayDetails.verificationData.localVerificationData}");
 | ||
|     sb.writeln("  => source: ${googlePlayDetails.verificationData.source}");
 | ||
|     sb.writeln("\n#### product ####");
 | ||
|     sb.writeln("price: ${googlePlayProduct.price}");
 | ||
|     sb.writeln("rawPrice: ${googlePlayProduct.rawPrice}");
 | ||
|     sb.writeln("currencyCode: ${googlePlayProduct.currencyCode}");
 | ||
|     sb.writeln("currencySymbol: ${googlePlayProduct.currencySymbol}");
 | ||
|     sb.writeln("productDetails:");
 | ||
| 
 | ||
|     final productDetails = googlePlayProduct.productDetails;
 | ||
|     sb.writeln("  => description: ${productDetails.name}");
 | ||
|     sb.writeln("  => freeTrialPeriod: ${productDetails.title}");
 | ||
|     sb.writeln("  => description: ${productDetails.description}");
 | ||
|     sb.writeln("  => freeTrialPeriod: ${productDetails.productType}");
 | ||
| 
 | ||
|     final oneTimeDetails = productDetails.oneTimePurchaseOfferDetails;
 | ||
|     if (oneTimeDetails != null) {
 | ||
|       sb.writeln("  => oneTimeDetails:");
 | ||
|       sb.writeln("    - formattedPrice: ${oneTimeDetails.formattedPrice}");
 | ||
|       sb.writeln("    - priceAmountMicros: ${oneTimeDetails.priceAmountMicros}");
 | ||
|       sb.writeln("    - priceCurrencyCode: ${oneTimeDetails.priceCurrencyCode}");
 | ||
|     }
 | ||
| 
 | ||
|     final subscriptionOfferDetails = productDetails.subscriptionOfferDetails;
 | ||
|     if (subscriptionOfferDetails != null && subscriptionOfferDetails.isNotEmpty) {
 | ||
|       for (var offer in subscriptionOfferDetails) {
 | ||
|         sb.writeln("  => sub offer: ${offer.offerId}");
 | ||
|         sb.writeln("    - basePlanId: ${offer.basePlanId}");
 | ||
|         sb.writeln("    - offerTag: ${offer.offerTags}");
 | ||
|         sb.writeln("    - offerIdToken: ${offer.offerIdToken}");
 | ||
|         final pricingPhases = offer.pricingPhases;
 | ||
|         for (var idx = 0; idx < pricingPhases.length; ++idx) {
 | ||
|           final phase = pricingPhases[idx];
 | ||
|           sb.writeln("    - pricingPhase[$idx]:");
 | ||
|           sb.writeln("        * billingCycleCount: ${phase.billingCycleCount}");
 | ||
|           sb.writeln("        * billingPeriod: ${phase.billingPeriod}");
 | ||
|           sb.writeln("        * formattedPrice: ${phase.formattedPrice}");
 | ||
|           sb.writeln("        * priceAmountMicros: ${phase.priceAmountMicros}");
 | ||
|           sb.writeln("        * priceCurrencyCode: ${phase.priceCurrencyCode}");
 | ||
|           sb.writeln("        * recurrenceMode: ${phase.recurrenceMode}");
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     return sb.toString();
 | ||
|   }
 | ||
| 
 | ||
|   Future reportOrders(ProductId productId, ProductDetails details, PurchaseDetails purchaseDetails,
 | ||
|       OrderEntity? order) async {
 | ||
|     final OrdersReport ordersReport = OrdersReport();
 | ||
| 
 | ||
|     if (Platform.isAndroid) {
 | ||
|       ordersReport.token = purchaseDetails.verificationData.serverVerificationData;
 | ||
|       ordersReport.packageName = GuruApp.instance.details.packageName;
 | ||
|       final manifest = order?.manifest;
 | ||
|       final basePlanId = manifest?.basePlanId;
 | ||
|       final offerId = manifest?.offerId;
 | ||
|       if (productId.isSubscription && basePlanId != null && offerId != null) {
 | ||
|         ordersReport.basePlanId = basePlanId;
 | ||
|         ordersReport.offerId = offerId;
 | ||
|       }
 | ||
|       try {
 | ||
|         GooglePlayPurchaseDetails googlePlayDetails = purchaseDetails as GooglePlayPurchaseDetails;
 | ||
|         GooglePlayProductDetails googlePlayProduct = details as GooglePlayProductDetails;
 | ||
|         Log.d(
 | ||
|             "Android Product/Purchase ${buildGooglePlayDetailsString(googlePlayProduct, googlePlayDetails)}");
 | ||
|       } catch (error, stacktrace) {}
 | ||
|     } else if (Platform.isIOS) {
 | ||
|       AppStorePurchaseDetails appleDetails = purchaseDetails as AppStorePurchaseDetails;
 | ||
|       AppStoreProductDetails appleProduct = details as AppStoreProductDetails;
 | ||
|       final StringBuffer sb = StringBuffer();
 | ||
|       sb.writeln("#### purchase ####");
 | ||
|       sb.writeln("productID: ${appleDetails.productID}");
 | ||
|       sb.writeln("purchaseID: ${appleDetails.purchaseID}");
 | ||
|       sb.writeln("transactionDate: ${appleDetails.transactionDate}");
 | ||
|       sb.writeln("verificationData: ${appleDetails.verificationData}");
 | ||
|       sb.writeln("status: ${appleDetails.status}");
 | ||
|       sb.writeln("skPaymentTransaction:");
 | ||
|       sb.writeln(
 | ||
|           "   =>originalTransaction:${appleDetails.skPaymentTransaction.originalTransaction}");
 | ||
|       sb.writeln("       =>${appleDetails.skPaymentTransaction.originalTransaction?.payment}");
 | ||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionState}:");
 | ||
|       sb.writeln("   =>${appleDetails.skPaymentTransaction.transactionIdentifier}:");
 | ||
|       sb.writeln("\n#### product ####");
 | ||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}");
 | ||
|       sb.writeln("rawPrice: ${appleProduct.rawPrice}");
 | ||
|       sb.writeln("currencyCode: ${appleProduct.currencyCode}");
 | ||
|       sb.writeln("currencyCode skProduct");
 | ||
|       sb.writeln("    =>localizedTitle: ${appleProduct.skProduct.localizedTitle}");
 | ||
|       sb.writeln("    =>localizedDescription: ${appleProduct.skProduct.localizedDescription}");
 | ||
|       sb.writeln("    =>priceLocale: ${appleProduct.skProduct.priceLocale}");
 | ||
|       sb.writeln("    =>productIdentifier: ${appleProduct.skProduct.productIdentifier}");
 | ||
|       sb.writeln(
 | ||
|           "    =>subscriptionGroupIdentifier: ${appleProduct.skProduct.subscriptionGroupIdentifier}");
 | ||
|       sb.writeln("    =>appleProduct.skProduct.priceLocale");
 | ||
|       sb.writeln("        ->{appleProduct.skProduct.priceLocale}");
 | ||
| 
 | ||
|       ordersReport.bundleId = GuruApp.instance.appSpec.details.bundleId;
 | ||
|       ordersReport.receipt = purchaseDetails.verificationData.serverVerificationData;
 | ||
|       ordersReport.sku = appleDetails.productID;
 | ||
|       ordersReport.countryCode = appleProduct.skProduct.priceLocale.countryCode;
 | ||
|       Log.d("IOS Product/Purchase ${sb.toString()}");
 | ||
|     }
 | ||
| 
 | ||
|     if (productId.isSubscription) {
 | ||
|       ordersReport.orderType = OrderType.subs;
 | ||
|       ordersReport.subscriptionId = details.id;
 | ||
|     } else {
 | ||
|       ordersReport.orderType = OrderType.inapp;
 | ||
|       ordersReport.productId = details.id;
 | ||
|     }
 | ||
|     ordersReport.orderId = purchaseDetails.purchaseID;
 | ||
|     ordersReport.price = details.rawPrice.toString();
 | ||
|     ordersReport.currency = details.currencyCode;
 | ||
|     ordersReport.orderUserInfo = OrderUserInfo(GuruSettings.instance.bestLevel.get().toString());
 | ||
|     ordersReport.userIdentification = GuruAnalytics.instance.userIdentification;
 | ||
| 
 | ||
|     final transactionDate = purchaseDetails.transactionDate;
 | ||
|     if (transactionDate != null && transactionDate.isNotEmpty) {
 | ||
|       try {
 | ||
|         final ts = int.tryParse(transactionDate) ?? DateTimeUtils.currentTimeInMillis();
 | ||
|         ordersReport.transactionDate = ts;
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("parse transactionDate error! $error", stackTrace: stacktrace);
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     Log.d("orderReport:$ordersReport", tag: "Iap");
 | ||
|     try {
 | ||
|       final result = await GuruApi.instance.reportOrders(ordersReport);
 | ||
|       // 这里不管返回什么值,都认为是成功的
 | ||
|       await logRevenue(ordersReport, result);
 | ||
|       return;
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.i("reportOrders error!", error: error, stackTrace: stacktrace);
 | ||
|     }
 | ||
|     AppProperty.getInstance().saveFailedIapOrders(ordersReport);
 | ||
|   }
 | ||
| 
 | ||
|   Future<bool> logRevenue(OrdersReport order, OrdersResponse result) async {
 | ||
|     final isSubscription = order.orderType == OrderType.subs;
 | ||
|     final sku = (isSubscription ? order.subscriptionId : order.productId) ??
 | ||
|         (order.productId ?? order.subscriptionId ?? order.sku);
 | ||
|     final usdPrice = result.usdPrice;
 | ||
|     if (sku == null || sku.isEmpty) {
 | ||
|       return false;
 | ||
|     }
 | ||
| 
 | ||
|     if (!result.isTestOrder && usdPrice <= 0) {
 | ||
|       Log.i("ignoreInvalidResult $result", tag: "Iap");
 | ||
|       return false;
 | ||
|     }
 | ||
|     Log.i("prepare logRevenue! $result $sku");
 | ||
| 
 | ||
|     final platform = Platform.isIOS ? "appstore" : "google_play";
 | ||
|     if (GuruApp.instance.appSpec.deployment.purchaseEventTrigger == 2) {
 | ||
|       GuruAnalytics.instance.logAdRevenue020(usdPrice, platform, "USD",
 | ||
|           orderId: order.orderId,
 | ||
|           orderType: isSubscription ? "SUB" : "IAP",
 | ||
|           productId: sku,
 | ||
|           transactionDate: order.transactionDate);
 | ||
|     } else {
 | ||
|       GuruAnalytics.instance.logAdRevenue(usdPrice, platform, "USD",
 | ||
|           orderId: order.orderId,
 | ||
|           orderType: isSubscription ? "SUB" : "IAP",
 | ||
|           productId: sku,
 | ||
|           transactionDate: order.transactionDate);
 | ||
|     }
 | ||
|     GuruAnalytics.instance
 | ||
|         .logPurchase(usdPrice, currency: 'USD', contentId: sku, adPlatform: platform);
 | ||
|     if (isSubscription) {
 | ||
|       GuruAnalytics.instance.logEvent(
 | ||
|           "sub_purchase",
 | ||
|           {
 | ||
|             "platform": platform,
 | ||
|             "currency": "USD",
 | ||
|             "revenue": usdPrice,
 | ||
|             "product_id": sku,
 | ||
|             "order_type": "SUB",
 | ||
|             "order_id": order.orderId,
 | ||
|             "trans_ts": order.transactionDate
 | ||
|           },
 | ||
|           options: iapRevenueAppEventOptions);
 | ||
|     } else {
 | ||
|       GuruAnalytics.instance.logEvent(
 | ||
|           "iap_purchase",
 | ||
|           {
 | ||
|             "platform": platform,
 | ||
|             "currency": "USD",
 | ||
|             "revenue": usdPrice,
 | ||
|             "product_id": sku,
 | ||
|             "order_type": "IAP",
 | ||
|             "order_id": order.orderId,
 | ||
|             "trans_ts": order.transactionDate
 | ||
|           },
 | ||
|           options: iapRevenueAppEventOptions);
 | ||
|     }
 | ||
|     GuruAnalytics.instance.logGuruEvent(
 | ||
|         "dev_iap_action", {"item_category": "reported", "item_name": sku, "result": "true"});
 | ||
|     Log.i("reportOrders completed! logRevenue success! $result $sku");
 | ||
|     return true;
 | ||
|   }
 | ||
| 
 | ||
|   Future _deliverManifest(ProductId productId, Manifest manifest) async {
 | ||
|     bool result = false;
 | ||
|     String cause = '';
 | ||
|     try {
 | ||
|       result = await ManifestManager.instance
 | ||
|           .deliver(manifest, TransactionMethod.iap)
 | ||
|           .catchError((error) {
 | ||
|         Log.w("applyManifest error:$error", syncCrashlytics: true, syncFirebase: true);
 | ||
|       });
 | ||
|     } catch (error, stacktrace) {
 | ||
|       cause = error.toString();
 | ||
|     }
 | ||
|     GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|       "item_category": "delivered",
 | ||
|       "item_name": productId.sku,
 | ||
|       "mc": manifest.category,
 | ||
|       "result": result ? "true" : "false",
 | ||
|       'cause': cause,
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   Future<bool> _completeOrder(OrderEntity order) async {
 | ||
|     bool result = false;
 | ||
|     try {
 | ||
|       final completedOrder = order.success();
 | ||
|       result = await GuruDB.instance.completeOrder(order: completedOrder);
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("_completePurchase error.$error",
 | ||
|           stackTrace: stacktrace, syncFirebase: true, syncCrashlytics: true);
 | ||
|     }
 | ||
| 
 | ||
|     GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|       "item_category": "complete",
 | ||
|       "item_name": order.productId.sku,
 | ||
|       "mc": order.manifest?.category ?? "unknown",
 | ||
|       "result": result ? "true" : "false"
 | ||
|     });
 | ||
|     final manifest = order.manifest;
 | ||
|     if (manifest != null) {
 | ||
|       await _deliverManifest(order.productId, manifest);
 | ||
|     }
 | ||
|     if (order.isAsset || order.isSubscription) {
 | ||
|       final changedPurchasedStore = purchasedStore.clone();
 | ||
|       changedPurchasedStore.addAsset(Asset(order.productId, order));
 | ||
|       _iapStoreSubject.addEx(changedPurchasedStore);
 | ||
|     }
 | ||
|     return true;
 | ||
|   }
 | ||
| 
 | ||
|   Future completePoints(
 | ||
|       ProductId productId, ProductDetails productDetails, PurchaseDetails details) async {
 | ||
|     final count = await AppProperty.getInstance().increaseAndGetIapCount();
 | ||
|     GuruAnalytics.instance.setUserProperty("purchase_count", count.toString());
 | ||
|     try {
 | ||
|       final cost = productDetails.rawPrice;
 | ||
|       if (cost > 0) {
 | ||
|         final double price = cost;
 | ||
|         if (count == 1) {
 | ||
|           GuruAnalytics.instance.logEventEx("first_iap",
 | ||
|               itemName: productId.sku,
 | ||
|               value: price,
 | ||
|               parameters: {"currency": productDetails.currencyCode});
 | ||
|           GuruAnalytics.instance.setUserProperty("is_iap_user", "true");
 | ||
|         }
 | ||
|         GuruAnalytics.instance.logEventEx(productId.iapEventName,
 | ||
|             itemName: productId.sku,
 | ||
|             value: price,
 | ||
|             parameters: {"currency": productDetails.currencyCode});
 | ||
|       }
 | ||
|     } catch (error, stacktrace) {
 | ||
|       GuruAnalytics.instance.logException(error, stacktrace: stacktrace);
 | ||
|     }
 | ||
|     final intent = productId.createIntent(scene: "outside_points");
 | ||
|     final manifest = await ManifestManager.instance.createManifest(intent);
 | ||
|     await _deliverManifest(productId, manifest);
 | ||
|     // 这里不需要传 order,因为 points 商品是非订阅商品
 | ||
|     await reportOrders(productId, productDetails, details, null);
 | ||
|   }
 | ||
| 
 | ||
|   Future<OrderEntity?> _completePurchase(ProductId definedProductId,
 | ||
|       ProductDetails originProductDetails, PurchaseDetails details) async {
 | ||
|     ProductId productId = definedProductId;
 | ||
|     await _inAppPurchase.completePurchase(details);
 | ||
|     final count = await AppProperty.getInstance().increaseAndGetIapCount();
 | ||
|     GuruAnalytics.instance.setUserProperty("purchase_count", count.toString());
 | ||
|     Log.d("_completePurchase $productId ${details.pendingCompletePurchase}", tag: "Iap");
 | ||
|     OrderEntity? resultOrder;
 | ||
| 
 | ||
|     IapRequest? iapRequest = iapRequestMap.remove(productId);
 | ||
|     if (iapRequest == null) {
 | ||
|       final offerProductIds = GuruApp.instance.offerProductIds(productId);
 | ||
|       for (var offerProductId in offerProductIds) {
 | ||
|         iapRequest = iapRequestMap.remove(offerProductId);
 | ||
|         if (iapRequest != null) {
 | ||
|           productId = offerProductId;
 | ||
|           break;
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     if (iapRequest != null) {
 | ||
|       resultOrder = iapRequest.order;
 | ||
|       final result = await _completeOrder(iapRequest.order);
 | ||
|       iapRequest.response(result);
 | ||
|     } else {
 | ||
|       Log.d("Not found iapRequest for $productId");
 | ||
|       final orders = await GuruDB.instance.getPendingOrders(productId);
 | ||
|       if (orders.isNotEmpty) {
 | ||
|         orders.sort((a, b) => b.timestamp.compareTo(a.timestamp));
 | ||
|         await _completeOrder(orders.first);
 | ||
|         resultOrder = orders.first;
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     final productDetails = iapRequest?.product.offerDetails ??
 | ||
|         iapRequest?.product.details ??
 | ||
|         loadedProductDetails[productId] ??
 | ||
|         originProductDetails;
 | ||
| 
 | ||
|     Log.d(
 | ||
|         "productId:$productId productDetails:${productDetails.rawPrice} originProductDetails:${originProductDetails.rawPrice}");
 | ||
| 
 | ||
|     try {
 | ||
|       // final item = pendingTransaction.product.item;
 | ||
|       final cost = productDetails.rawPrice;
 | ||
|       if (cost > 0) {
 | ||
|         final double price = cost;
 | ||
|         if (count == 1) {
 | ||
|           GuruAnalytics.instance.logEventEx("first_iap",
 | ||
|               itemName: productId.sku,
 | ||
|               value: price,
 | ||
|               parameters: {"currency": productDetails.currencyCode});
 | ||
|           GuruAnalytics.instance.setUserProperty("is_iap_user", "true");
 | ||
|         }
 | ||
|         GuruAnalytics.instance.logEventEx(productId.iapEventName,
 | ||
|             itemName: productId.sku,
 | ||
|             value: price,
 | ||
|             parameters: {"currency": productDetails.currencyCode});
 | ||
|       }
 | ||
|     } catch (error, stacktrace) {
 | ||
|       GuruAnalytics.instance.logException(error, stacktrace: stacktrace);
 | ||
|     }
 | ||
|     if (productId.isSubscription) {
 | ||
|       if (resultOrder != null) {
 | ||
|         recordSubscription(resultOrder);
 | ||
|       }
 | ||
|     }
 | ||
|     if (resultOrder != null) {
 | ||
|       reportOrders(productId, productDetails, details, resultOrder);
 | ||
|     }
 | ||
| 
 | ||
|     return resultOrder;
 | ||
|   }
 | ||
| 
 | ||
|   Future recordSubscription(OrderEntity order) async {
 | ||
|     final sku = order.sku;
 | ||
|     final manifest = order.manifest;
 | ||
|     final productId = ProductId.fromSku(
 | ||
|         sku: sku,
 | ||
|         attr: TransactionAttributes.subscriptions,
 | ||
|         basePlan: manifest?.basePlanId,
 | ||
|         offerId: manifest?.offerId);
 | ||
| 
 | ||
|     final group = GuruApp.instance.appSpec.productProfile.group(productId);
 | ||
| 
 | ||
|     final appProperty = AppProperty.getInstance();
 | ||
| 
 | ||
|     await appProperty.getAndIncrease(PropertyKeys.subscriptionCount);
 | ||
|     if (group != null) {
 | ||
|       await appProperty.getAndIncrease(PropertyKeys.buildGroupSubscriptionCount(group));
 | ||
|     }
 | ||
|     await appProperty.getAndIncrease(PropertyKeys.buildSubscriptionCount(productId));
 | ||
|   }
 | ||
| 
 | ||
|   Future<Manifest> createPurchaseManifest(TransactionIntent intent) {
 | ||
|     return ManifestManager.instance.createManifest(intent);
 | ||
|   }
 | ||
| 
 | ||
|   Future<ProductDetails?> checkAndDistributeOfferDetails(
 | ||
|       ProductId productId, ProductDetails? details, EligibilityCriteria eligibilityCriteria) async {
 | ||
|     Log.d("checkAndDistributeOfferDetails $productId $eligibilityCriteria");
 | ||
|     switch (eligibilityCriteria) {
 | ||
|       case EligibilityCriteria.newCustomerNeverHadSubscribedThisGroup:
 | ||
|         final group = GuruApp.instance.appSpec.productProfile.group(productId);
 | ||
|         if (group != null) {
 | ||
|           final key = PropertyKeys.buildGroupSubscriptionCount(group);
 | ||
|           final count = await AppProperty.getInstance().getInt(key, defValue: 0);
 | ||
|           Log.d("  ==> $key $count");
 | ||
|           return count > 0 ? null : details;
 | ||
|         }
 | ||
|         Log.d("  ==> not found group($group)! return null");
 | ||
|         break;
 | ||
|       case EligibilityCriteria.newCustomerNeverHadThisSubscription:
 | ||
|         final key = PropertyKeys.buildSubscriptionCount(productId);
 | ||
|         final count = await AppProperty.getInstance().getInt(key, defValue: 0);
 | ||
|         Log.d("  ==> $key $count");
 | ||
|         return count > 0 ? null : details;
 | ||
|       case EligibilityCriteria.newCustomerNeverHadAnySubscription:
 | ||
|         final count =
 | ||
|             await AppProperty.getInstance().getInt(PropertyKeys.subscriptionCount, defValue: 0);
 | ||
|         Log.d("  ==> subscriptionCount $count");
 | ||
|         return count > 0 ? null : details;
 | ||
|       default:
 | ||
|         return details;
 | ||
|     }
 | ||
|     return null;
 | ||
|   }
 | ||
| 
 | ||
|   Future<IapProduct> _createProduct(TransactionIntent intent, ProductDetails details) async {
 | ||
|     final productId = intent.productId;
 | ||
|     Manifest manifest = await ManifestManager.instance.createManifest(intent);
 | ||
|     Log.d("createProduct ${productId.sku} ${productId.hasBasePlan}");
 | ||
|     ProductDetails baseDetails = details;
 | ||
|     ProductDetails? offerDetails;
 | ||
|     if (Platform.isAndroid && productId.isSubscription && productId.hasBasePlan) {
 | ||
|       final googlePlayProductDetails = details as GooglePlayProductDetails;
 | ||
|       final productDetails = googlePlayProductDetails.productDetails;
 | ||
|       final subscriptionOfferDetails = productDetails.subscriptionOfferDetails;
 | ||
|       final offerProductDetails = GooglePlayProductDetails.fromProductDetails(productDetails);
 | ||
|       final expectBasePlan = productId.basePlan;
 | ||
|       final expectOfferId = productId.offerId;
 | ||
|       Log.d(
 | ||
|           "expectOfferId:$expectOfferId expectBasePlan:$expectBasePlan offers:${offerProductDetails.length}");
 | ||
| 
 | ||
|       if (expectBasePlan != null &&
 | ||
|           subscriptionOfferDetails != null &&
 | ||
|           subscriptionOfferDetails.length >= offerProductDetails.length) {
 | ||
|         for (int i = 0; i < subscriptionOfferDetails.length; i++) {
 | ||
|           final offer = subscriptionOfferDetails[i];
 | ||
|           Log.d(
 | ||
|               "$i expectOfferId:$expectOfferId offerId:${offer.offerId} expectBasePlan:$expectBasePlan basePlanId:${offer.basePlanId}");
 | ||
|           if (expectBasePlan != offer.basePlanId) {
 | ||
|             continue;
 | ||
|           }
 | ||
|           if (offer.offerId == null) {
 | ||
|             baseDetails = offerProductDetails[i];
 | ||
|           } else if (expectOfferId != null && expectOfferId == offer.offerId) {
 | ||
|             offerDetails = offerProductDetails[i];
 | ||
|           }
 | ||
|         }
 | ||
|         try {
 | ||
|           offerDetails = await checkAndDistributeOfferDetails(
 | ||
|               productId, offerDetails, intent.eligibilityCriteria);
 | ||
|         } catch (error, stacktrace) {
 | ||
|           Log.w("checkAndDistributeOfferDetails error! $error $stacktrace");
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     return Product.iap(productId, baseDetails, manifest, offerDetails: offerDetails) as IapProduct;
 | ||
|   }
 | ||
| 
 | ||
|   Future<ProductStore<IapProduct>> buildProducts(Set<TransactionIntent> intents) async {
 | ||
|     ProductStore<IapProduct> iapStore = ProductStore();
 | ||
|     final _productDetails = loadedProductDetails;
 | ||
|     for (var intent in intents) {
 | ||
|       // 这里需要使用原始 ID 进行查找
 | ||
|       final productId = intent.productId.originId;
 | ||
|       final details = _productDetails[productId];
 | ||
|       Log.d("buildProducts $productId $details");
 | ||
|       if (details == null) {
 | ||
|         continue;
 | ||
|       }
 | ||
|       final product = await _createProduct(intent, details);
 | ||
|       iapStore.putProduct(product);
 | ||
|       if (intent.productId.hasOffer && !iapStore.existsProduct(productId)) {
 | ||
|         final originProduct =
 | ||
|             await _createProduct(productId.createIntent(scene: intent.scene), details);
 | ||
|         iapStore.putProduct(originProduct);
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     return iapStore;
 | ||
|   }
 | ||
| 
 | ||
|   Future<bool> buy(IapProduct product) async {
 | ||
|     final productId = product.productId.originId;
 | ||
|     final asset = purchasedStore.getAsset(productId);
 | ||
|     if (asset != null) {
 | ||
|       Log.v("IAP buy ${asset.productId} direct success!");
 | ||
|       return true;
 | ||
|     }
 | ||
|     final pendingProduct = iapRequestMap[productId];
 | ||
|     if (pendingProduct != null) {
 | ||
|       Log.v("_requestPurchases has pending product");
 | ||
|       return pendingProduct.completer.future;
 | ||
|     }
 | ||
|     final param = PurchaseParam(
 | ||
|         productDetails: product.offerDetails ?? product.details,
 | ||
|         applicationUserName: AccountDataStore.instance.user?.uid);
 | ||
| 
 | ||
|     late OrderEntity order;
 | ||
|     try {
 | ||
|       order = product.createOrder();
 | ||
|       await GuruDB.instance.upsertOrder(order: order);
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.w("addOrder error! $error",
 | ||
|           stackTrace: stacktrace, syncCrashlytics: true, syncFirebase: true);
 | ||
|       return false;
 | ||
|     }
 | ||
| 
 | ||
|     bool result = false;
 | ||
|     if (product.isConsumable()) {
 | ||
|       result = await _inAppPurchase.buyConsumable(purchaseParam: param);
 | ||
|     } else {
 | ||
|       result = await _inAppPurchase.buyNonConsumable(purchaseParam: param);
 | ||
|     }
 | ||
|     if (!result) {
 | ||
|       Log.d("_requestPurchases error! ${product.productId} ${product.details.price}",
 | ||
|           syncFirebase: true);
 | ||
|       GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|         "item_category": "request",
 | ||
|         "item_name": order.productId.sku,
 | ||
|         "mc": order.manifest?.category ?? "unknown",
 | ||
|         "result": "false",
 | ||
|         "cause": "buy error"
 | ||
|       });
 | ||
|       await GuruDB.instance.deleteOrder(order: order);
 | ||
|       return false;
 | ||
|     } else {
 | ||
|       GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|         "item_category": "request",
 | ||
|         "item_name": order.productId.sku,
 | ||
|         "mc": order.manifest?.category ?? "unknown",
 | ||
|         "result": "true"
 | ||
|       });
 | ||
|     }
 | ||
|     final completer = Completer<bool>();
 | ||
|     final iapRequest = IapRequest(product, order, completer);
 | ||
|     iapRequestMap[productId] = iapRequest;
 | ||
|     return await completer.future;
 | ||
|   }
 | ||
| 
 | ||
|   Future clearAssetRecord() async {
 | ||
|     if (!Platform.isAndroid) {
 | ||
|       return;
 | ||
|     }
 | ||
|     final InAppPurchaseAndroidPlatformAddition androidAddition =
 | ||
|         _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
 | ||
|     final response = await androidAddition.queryPastPurchases();
 | ||
|     for (var purchase in response.pastPurchases) {
 | ||
|       androidAddition.consumePurchase(purchase);
 | ||
|       Log.w("[clearAssetRecord] purchase clear:${purchase.productID} ${purchase.verificationData}");
 | ||
|       _inAppPurchase.completePurchase(purchase);
 | ||
|     }
 | ||
| 
 | ||
|     await GuruDB.instance.clearOrders(method: TransactionMethod.iap);
 | ||
|     final newPurchased = AssetsStore<IapAsset>();
 | ||
|     _iapStoreSubject.addEx(newPurchased);
 | ||
|   }
 | ||
| 
 | ||
|   Future manualConsumePurchase(PurchaseDetails purchase) async {
 | ||
|     if (Platform.isAndroid) {
 | ||
|       final InAppPurchaseAndroidPlatformAddition androidAddition =
 | ||
|           _inAppPurchase.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
 | ||
|       await androidAddition.consumePurchase(purchase);
 | ||
|       _inAppPurchase.completePurchase(purchase);
 | ||
|       await GuruDB.instance.deletePendingOrderBySku(sku: purchase.productID);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future manualConsumeAllPurchases(List<Tuple2<ProductId, PurchaseDetails>> tuples) async {
 | ||
|     for (var tuple in tuples) {
 | ||
|       try {
 | ||
|         final productId = tuple.item1;
 | ||
|         final purchase = tuple.item2;
 | ||
|         await manualConsumePurchase(purchase);
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("consumePurchase error! $error", stackTrace: stacktrace, syncFirebase: true);
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future completeAllPurchases(List<Tuple2<ProductId, PurchaseDetails>> tuples) async {
 | ||
|     for (var tuple in tuples) {
 | ||
|       try {
 | ||
|         final productId = tuple.item1;
 | ||
|         final details = tuple.item2;
 | ||
|         final productDetails = loadedProductDetails[productId];
 | ||
|         if (productDetails != null) {
 | ||
|           if (details.pendingCompletePurchase) {
 | ||
|             GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|               "item_category": "pending_complete",
 | ||
|               "item_name": productId.sku,
 | ||
|               "result": "true",
 | ||
|             });
 | ||
|             final order = await _completePurchase(productId, productDetails, details);
 | ||
|           } else {
 | ||
|             GuruAnalytics.instance.logGuruEvent("dev_iap_action", {
 | ||
|               "item_category": "pending_consume",
 | ||
|               "item_name": productId.sku,
 | ||
|               "result": "true",
 | ||
|             });
 | ||
|             await manualConsumePurchase(details);
 | ||
|             if (productId.isPoints) {
 | ||
|               try {
 | ||
|                 await completePoints(productId, productDetails, details);
 | ||
|               } catch (error, stacktrace) {
 | ||
|                 Log.w("completePoints error! $error", stackTrace: stacktrace, syncFirebase: true);
 | ||
|               }
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       } catch (error, stacktrace) {
 | ||
|         Log.w("consumePurchase error! $error", stackTrace: stacktrace, syncFirebase: true);
 | ||
|       }
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
| //
 | ||
| // Future<List<IapProduct>> refreshAllProducts() async {
 | ||
| //   final allProductIds = ProductIds.allIapProductIds;
 | ||
| //
 | ||
| //   return await refreshProducts(allProductIds);
 | ||
| // }
 | ||
| 
 | ||
|   Map<String, ProductId> _filterProductSkus(
 | ||
|       {required Set<ProductId> ids, required Set<int> attrs, Set<ProductId>? validIds}) {
 | ||
|     final List<MapEntry<String, ProductId>> entries = ids
 | ||
|         .where((productId) =>
 | ||
|             (validIds?.contains(productId) != false) && attrs.contains(productId.attr))
 | ||
|         .map((productId) => MapEntry(productId.sku, productId))
 | ||
|         .toList();
 | ||
|     return Map.fromEntries(entries);
 | ||
|   }
 | ||
| 
 | ||
|   Future<ProductDetailsResponse> _queryProducts(Set<String> skus) async {
 | ||
|     try {
 | ||
|       return await _inAppPurchase.queryProductDetails(skus);
 | ||
|     } catch (error, stacktrace) {
 | ||
|       Log.i("_getProducts error:$error $stacktrace");
 | ||
|     }
 | ||
|     return _emptyResponse;
 | ||
|   }
 | ||
| 
 | ||
|   Future restorePurchases() async {
 | ||
|     Log.d("restorePurchases!");
 | ||
|     if (!iapAvailable) {
 | ||
|       Log.w("ignore restorePurchases! iap service not available!", tag: "IAP");
 | ||
|       return;
 | ||
|     }
 | ||
|     if (!_restorePurchase) {
 | ||
|       _restorePurchase = Platform.isAndroid; // 只有Android需要进行处理
 | ||
|       return await _inAppPurchase.restorePurchases();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   Future refreshProducts() async {
 | ||
|     if (!iapAvailable) {
 | ||
|       Log.w("ignore refreshProducts! iap service not available!", tag: "IAP");
 | ||
|       return;
 | ||
|     }
 | ||
|     final validIds = GuruApp.instance.productProfile.oneOffChargeIapIds.toSet()
 | ||
|       ..removeAll(loadedProductDetails.keys.toSet());
 | ||
|     final queryOneOffChargeSkuMap = _filterProductSkus(
 | ||
|         ids: GuruApp.instance.productProfile.oneOffChargeIapIds,
 | ||
|         attrs: TransactionAttributes.oneOffChargeAttributes,
 | ||
|         validIds: validIds);
 | ||
| 
 | ||
|     Log.i("refreshProduct $queryOneOffChargeSkuMap", tag: "IAP");
 | ||
|     final Map<ProductId, ProductDetails> detailsMap = {};
 | ||
| 
 | ||
|     if (queryOneOffChargeSkuMap.isEmpty) {
 | ||
|       Log.i("refreshProducts ignore! already loaded!", tag: "IAP");
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     final queryProductIds = queryOneOffChargeSkuMap.keys.toSet();
 | ||
|     queryProductIds.addAll(GuruApp.instance.productProfile.subscriptionsIapIds.map((e) => e.sku));
 | ||
|     Log.d("refresh product:", tag: "IAP");
 | ||
|     for (var productId in queryProductIds) {
 | ||
|       Log.d("  => $productId", tag: "IAP");
 | ||
|     }
 | ||
|     final response = await _queryProducts(queryProductIds).catchError((error, stacktrace) {
 | ||
|       Log.e("getProducts($queryOneOffChargeSkuMap}) error: $error $stacktrace", tag: "IAP");
 | ||
|       return _emptyResponse;
 | ||
|     });
 | ||
|     Log.i("refreshProduct COMPLETED:", tag: "IAP");
 | ||
|     for (var details in response.productDetails) {
 | ||
|       Log.i(" => ${details.id}", tag: "IAP");
 | ||
|     }
 | ||
| 
 | ||
|     Log.i("refreshProduct notFoundId:", tag: "IAP");
 | ||
|     for (var id in response.notFoundIDs) {
 | ||
|       Log.i(" => $id", tag: "IAP");
 | ||
|     }
 | ||
| 
 | ||
|     for (var details in response.productDetails) {
 | ||
|       detailsMap.addAll(extractProducts(details));
 | ||
|     }
 | ||
| 
 | ||
|     GuruAnalytics.instance
 | ||
|         .logGuruEvent("dev_iap_action", {"item_category": "load", "result": "true"});
 | ||
|     final newProductDetails = Map.of(loadedProductDetails);
 | ||
|     newProductDetails.addAll(detailsMap);
 | ||
|     _productDetailsSubject.addEx(newProductDetails);
 | ||
|   }
 | ||
| 
 | ||
|   Map<ProductId, ProductDetails> extractProducts(ProductDetails details) {
 | ||
|     final productId = GuruApp.instance.findProductId(sku: details.id);
 | ||
|     final Map<ProductId, ProductDetails> detailsMap = {};
 | ||
|     if (productId == null) {
 | ||
|       return detailsMap;
 | ||
|     }
 | ||
| 
 | ||
|     detailsMap[productId] = details;
 | ||
| 
 | ||
|     final ids = GuruApp.instance.offerProductIds(productId);
 | ||
|     if (ids.isNotEmpty) {
 | ||
|       final googlePlayProductDetails = details as GooglePlayProductDetails;
 | ||
|       final productDetails = googlePlayProductDetails.productDetails;
 | ||
|       final subscriptionOfferDetails = productDetails.subscriptionOfferDetails;
 | ||
|       final offerProductDetails = GooglePlayProductDetails.fromProductDetails(productDetails);
 | ||
|       for (var id in ids) {
 | ||
|         final expectBasePlan = id.basePlan;
 | ||
|         final expectOfferId = id.offerId;
 | ||
|         if (expectBasePlan != null &&
 | ||
|             subscriptionOfferDetails != null &&
 | ||
|             subscriptionOfferDetails.length == offerProductDetails.length) {
 | ||
|           for (int i = 0; i < subscriptionOfferDetails.length; i++) {
 | ||
|             final offer = subscriptionOfferDetails[i];
 | ||
|             Log.d(
 | ||
|                 "$i expectOfferId:$expectOfferId offerId:${offer.offerId} expectBasePlan:$expectBasePlan basePlanId:${offer.basePlanId}");
 | ||
|             if (expectBasePlan != offer.basePlanId || expectOfferId != offer.offerId) {
 | ||
|               continue;
 | ||
|             }
 | ||
|             detailsMap[id] = offerProductDetails[i];
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
|     return detailsMap;
 | ||
|   }
 | ||
| }
 |