#nullable enable using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { /// /// The main controller for Applications using Unity Purchasing. /// internal class PurchasingManager : IStoreCallback, IStoreController { private readonly IStore m_Store; private IInternalStoreListener? m_Listener; private readonly ILogger m_Logger; private readonly TransactionLog m_TransactionLog; private readonly string m_StoreName; private readonly IUnityServicesInitializationChecker m_UnityServicesInitializationChecker; private Action? m_AdditionalProductsCallback; private Action? m_AdditionalProductsFailCallback; private Action? m_AdditionalProductsDetailedFailCallback; private readonly HashSet purchasesProcessedInSession = new HashSet(); /// /// Stores may opt to disable Unity IAP's transaction log. /// public bool useTransactionLog { get; set; } internal PurchasingManager(TransactionLog tDb, ILogger logger, IStore store, string storeName, IUnityServicesInitializationChecker unityServicesInitializationChecker) { m_TransactionLog = tDb; m_Store = store; m_Logger = logger; m_StoreName = storeName; useTransactionLog = true; m_UnityServicesInitializationChecker = unityServicesInitializationChecker; } public void InitiatePurchase(Product product) { InitiatePurchase(product, string.Empty); } public void InitiatePurchase(string? productId) { InitiatePurchase(productId, string.Empty); } public void InitiatePurchase(Product? product, string developerPayload) { m_UnityServicesInitializationChecker.CheckAndLogWarning(); if (null == product) { m_Logger.LogIAPWarning("Trying to purchase null Product"); return; } if (!product.availableToPurchase) { m_Listener?.OnPurchaseFailed(product, new PurchaseFailureDescription(product.transactionID, PurchaseFailureReason.ProductUnavailable, "No products were found when fetching from the store")); return; } m_Store.Purchase(product.definition, developerPayload); } public void InitiatePurchase(string? purchasableId, string developerPayload) { var product = products.WithID(purchasableId); if (null == product) { m_Logger.LogFormat(LogType.Warning, "Unable to purchase unknown product with id: {0}", purchasableId); } InitiatePurchase(product, developerPayload); } /// /// Where an Application returned ProcessingResult.Pending they can manually /// finish the transaction by calling this method. /// public void ConfirmPendingPurchase(Product product) { if (null == product) { m_Logger.LogIAPError("Unable to confirm purchase with null Product"); return; } if (string.IsNullOrEmpty(product.transactionID)) { m_Logger.LogIAPError("Unable to confirm purchase; Product has missing or empty transactionID"); return; } if (useTransactionLog) { m_TransactionLog.Record(product.transactionID); } m_Store.FinishTransaction(product.definition, product.transactionID); m_Listener?.SendTransactionEvent(product); } public ProductCollection products { get; private set; } = null!; /// /// Called by our IStore when a purchase succeeds. /// public void OnPurchaseSucceeded(string id, string? receipt, string transactionId) { var product = products.WithStoreSpecificID(id); if (null == product) { // If is possible for stores to tell us about products we have not yet // requested details of. // We should still tell the App in this scenario, albeit with incomplete information. var definition = new ProductDefinition(id, ProductType.NonConsumable); product = new Product(definition, new ProductMetadata()); } UpdateProductReceiptAndTransactionID(product, receipt, transactionId); ProcessPurchaseIfNew(product); } void UpdateProductReceiptAndTransactionID(Product product, string? receipt, string transactionId) { if (product != null) { product.receipt = CreateUnifiedReceipt(receipt, transactionId); product.transactionID = transactionId; } } public void OnAllPurchasesRetrieved(List purchasedProducts) { if (products != null) { foreach (var product in products.all) { var purchasedProduct = purchasedProducts?.FirstOrDefault(firstPurchasedProduct => firstPurchasedProduct.definition.id == product.definition.id); if (purchasedProduct != null) { HandlePurchaseRetrieved(product, purchasedProduct); } else { ClearProductReceipt(product); } } } } // TODO IAP-2929: Add this to IStoreCallback in a major release internal static void OnEntitlementRevoked(Product revokedProduct) { ClearProductReceipt(revokedProduct); } void HandlePurchaseRetrieved(Product product, Product purchasedProduct) { UpdateProductReceiptAndTransactionID(product, purchasedProduct.receipt, purchasedProduct.transactionID); if (initialized && !WasPurchaseAlreadyProcessed(purchasedProduct.transactionID)) { ProcessPurchaseIfNew(product); } } bool WasPurchaseAlreadyProcessed(string transactionId) { return purchasesProcessedInSession.Contains(transactionId); } static void ClearProductReceipt(Product product) { product.receipt = null; product.transactionID = null; } [Obsolete] public void OnSetupFailed(InitializationFailureReason reason) { OnSetupFailed(reason, null); } public void OnSetupFailed(InitializationFailureReason reason, string? message) { if (initialized) { m_AdditionalProductsFailCallback?.Invoke(reason); m_AdditionalProductsDetailedFailCallback?.Invoke(reason, message); } else { m_Listener?.OnInitializeFailed(reason, message); } } public void OnPurchaseFailed(PurchaseFailureDescription description) { if (description != null) { var product = products.WithStoreSpecificID(description.productId); if (null == product) { m_Logger.LogFormat(LogType.Error, "Failed to purchase unknown product {0}", "productId:" + description.productId + " reason:" + description.reason + " message:" + description.message); return; } m_Logger.LogFormat(LogType.Warning, "onPurchaseFailedEvent({0})", "productId:" + product.definition.id + " message:" + description.message); m_Listener?.OnPurchaseFailed(product, description); } } /// /// Called back by our IStore when it has fetched the latest product data. /// public void OnProductsRetrieved(List products) { var unknownProducts = new HashSet(); foreach (var product in products) { var matchedProduct = this.products.WithStoreSpecificID(product.storeSpecificId); if (null == matchedProduct) { var definition = new ProductDefinition(product.storeSpecificId, product.storeSpecificId, product.type); matchedProduct = new Product(definition, product.metadata); unknownProducts.Add(matchedProduct); } matchedProduct.availableToPurchase = true; matchedProduct.metadata = product.metadata; matchedProduct.transactionID = product.transactionId; if (!string.IsNullOrEmpty(product.receipt)) { matchedProduct.receipt = CreateUnifiedReceipt(product.receipt, product.transactionId); } } if (unknownProducts.Count > 0) { this.products.AddProducts(unknownProducts); } // Fire our initialisation events if this is a first poll. CheckForInitialization(); ProcessPurchaseOnStart(); } string CreateUnifiedReceipt(string? rawReceipt, string transactionId) { return UnifiedReceiptFormatter.FormatUnifiedReceipt(rawReceipt, transactionId, m_StoreName); } void ProcessPurchaseOnStart() { foreach (var product in products.set) { if (!string.IsNullOrEmpty(product.receipt) && !string.IsNullOrEmpty(product.transactionID)) { ProcessPurchaseIfNew(product); } } } [Obsolete] public void FetchAdditionalProducts(HashSet additionalProducts, Action successCallback, Action failCallback) { m_AdditionalProductsCallback = successCallback; m_AdditionalProductsFailCallback = failCallback; products.AddProducts(additionalProducts.Select(x => new Product(x, new ProductMetadata()))); m_Store.RetrieveProducts(new ReadOnlyCollection(additionalProducts.ToList())); } public void FetchAdditionalProducts(HashSet additionalProducts, Action successCallback, Action failCallback) { m_AdditionalProductsCallback = successCallback; m_AdditionalProductsDetailedFailCallback = failCallback; products.AddProducts(additionalProducts.Select(x => new Product(x, new ProductMetadata()))); m_Store.RetrieveProducts(new ReadOnlyCollection(additionalProducts.ToList())); } /// /// Checks the product's transaction ID for uniqueness /// against the transaction log and calls the Application's /// ProcessPurchase method if so. /// private void ProcessPurchaseIfNew(Product product) { if (HasRecordedTransaction(product.transactionID)) { m_Store.FinishTransaction(product.definition, product.transactionID); return; } purchasesProcessedInSession.Add(product.transactionID); var p = new PurchaseEventArgs(product); // Applications may elect to delay confirmations of purchases, // such as when persisting purchase state asynchronously. if (m_Listener?.ProcessPurchase(p) == PurchaseProcessingResult.Complete) { ConfirmPendingPurchase(product); } } bool HasRecordedTransaction(string transactionId) { return useTransactionLog && m_TransactionLog.HasRecordOf(transactionId); } private bool initialized; private void CheckForInitialization() { if (!initialized) { initialized = true; var hasAvailableProductsToPurchase = HasAvailableProductsToPurchase(); if (hasAvailableProductsToPurchase) { m_Listener?.OnInitialized(this); } else { m_Listener?.OnInitializeFailed(InitializationFailureReason.NoProductsAvailable); } } else { m_AdditionalProductsCallback?.Invoke(); } } bool HasAvailableProductsToPurchase(bool shouldLogUnavailableProducts = true) { var available = false; foreach (var product in products.set) { if (product.availableToPurchase) { available = true; } else if (shouldLogUnavailableProducts) { m_Logger.LogFormat(LogType.Warning, "Unavailable product {0}-{1}", product.definition.id, product.definition.storeSpecificId); } } return available; } public void Initialize(IInternalStoreListener listener, HashSet products) { m_Listener = listener; m_Store.Initialize(this); var prods = products.Select(x => new Product(x, new ProductMetadata())).ToArray(); this.products = new ProductCollection(prods); var productCollection = new ReadOnlyCollection(products.ToList()); // Start the initialisation process by fetching product metadata. m_Store.RetrieveProducts(productCollection); } } }