#nullable enable using System; using System.Collections.Generic; using System.Linq; using AOT; using Uniject; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.MiniJSON; using UnityEngine.Purchasing.Security; using UnityEngine.Purchasing.Telemetry; namespace UnityEngine.Purchasing { /// /// App Store implementation of . /// class AppleStoreImpl : JSONStore, IAppleExtensions, IAppleConfiguration { Action? m_DeferredCallback; Action>? m_RevokedCallback; Action? m_RefreshReceiptError; Action? m_RefreshReceiptSuccess; Action? m_ObsoleteRestoreCallback; Action? m_RestoreCallback; Action? m_FetchStorePromotionOrderError; Action>? m_FetchStorePromotionOrderSuccess; Action? m_PromotionalPurchaseCallback; Action? m_FetchStorePromotionVisibilityError; Action? m_FetchStorePromotionVisibilitySuccess; INativeAppleStore? m_Native; readonly ITelemetryDiagnostics m_TelemetryDiagnostics; static IUtil? s_Util; static AppleStoreImpl? s_Instance; string? m_CachedAppReceipt; double? m_CachedAppReceiptModificationDate; string? m_ProductsJson; protected AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics) { s_Util = util; s_Instance = this; m_TelemetryDiagnostics = telemetryDiagnostics; m_ProductDescriptionsDeserializer = new AppleJsonProductDescriptionsDeserializer(); } public void SetNativeStore(INativeAppleStore apple) { base.SetNativeStore(apple); m_Native = apple; apple.SetUnityPurchasingCallback(MessageCallback); } public string? appReceipt { get { var receiptModificationDate = appReceiptModificationDate; if (!m_CachedAppReceiptModificationDate.Equals(receiptModificationDate)) { m_CachedAppReceiptModificationDate = m_Native?.appReceiptModificationDate; m_CachedAppReceipt = m_Native?.appReceipt; } return m_CachedAppReceipt; } } double? appReceiptModificationDate => m_Native?.appReceiptModificationDate; public bool canMakePayments => m_Native is { canMakePayments: true }; public void SetApplePromotionalPurchaseInterceptorCallback(Action callback) { m_PromotionalPurchaseCallback = callback; } public bool simulateAskToBuy { get => m_Native is { simulateAskToBuy: true }; set { if (m_Native != null) { m_Native.simulateAskToBuy = value; } } } public virtual void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) { m_FetchStorePromotionOrderError = errorCallback; m_FetchStorePromotionOrderSuccess = successCallback; m_Native?.FetchStorePromotionOrder(); } public virtual void FetchStorePromotionVisibility(Product product, Action successCallback, Action errorCallback) { m_FetchStorePromotionVisibilityError = errorCallback; m_FetchStorePromotionVisibilitySuccess = successCallback; m_Native?.FetchStorePromotionVisibility(product.definition.id); } public virtual void SetStorePromotionOrder(List products) { // Encode product list as a json doc containing an array of store-specific ids: // { "products": [ "ssid1", "ssid2" ] } var productIds = new List(); foreach (var p in products) { if (p != null && !string.IsNullOrEmpty(p.definition.storeSpecificId)) { productIds.Add(p.definition.storeSpecificId); } } var dict = new Dictionary { { "products", productIds } }; m_Native?.SetStorePromotionOrder(MiniJson.JsonEncode(dict)); } public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisibility visibility) { if (product == null) { var ex = new ArgumentNullException(nameof(product)); m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.InvalidProductError, ex); throw ex; } m_Native?.SetStorePromotionVisibility(product.definition.storeSpecificId, visibility.ToString()); } public string GetTransactionReceiptForProduct(Product product) { return m_Native?.GetTransactionReceiptForProductId(product.definition.storeSpecificId) ?? string.Empty; } public void SetApplicationUsername(string applicationUsername) { m_Native?.SetApplicationUsername(applicationUsername); } public override void OnProductsRetrieved(string json) { // base.OnProductsRetrieved (json); // Don't call this, because we want to enrich the products first // get product list var productDescriptions = m_ProductDescriptionsDeserializer.DeserializeProductDescriptions(json); List? finalProductDescriptions = null; m_ProductsJson = json; // parse app receipt var appleReceipt = GetAppleReceiptFromBase64String(appReceipt); if (HasInAppPurchaseReceipts(appleReceipt)) { finalProductDescriptions = EnrichProductDescriptions(productDescriptions, appleReceipt!); } // Pass along the enriched product descriptions unity.OnProductsRetrieved(finalProductDescriptions ?? productDescriptions); // If there is a promotional purchase callback, tell the store to intercept those purchases. if (m_PromotionalPurchaseCallback != null) { m_Native?.InterceptPromotionalPurchases(); } // Indicate we are ready to start receiving payments. m_Native?.AddTransactionObserver(); } bool HasInAppPurchaseReceipts(AppleReceipt? appleReceipt) { return appleReceipt != null && appleReceipt.inAppPurchaseReceipts?.Length > 0; } List EnrichProductDescriptions(List productDescriptions, AppleReceipt appleReceipt) { // Enrich the product descriptions with parsed receipt data var finalProductDescriptions = new List(); foreach (var productDescription in productDescriptions) { // JDRjr this Find may not be sufficient for subscriptions (or even multiple non-consumables?) var mostRecentReceipt = FindMostRecentReceipt(appleReceipt, productDescription.storeSpecificId); if (mostRecentReceipt == null) { finalProductDescriptions.Add(productDescription); } else { var productType = (AppleStoreProductType)Enum.Parse(typeof(AppleStoreProductType), mostRecentReceipt.productType.ToString()); if (productType == AppleStoreProductType.AutoRenewingSubscription) { // if the product is auto-renewing subscription, filter the expired products if (new SubscriptionInfo(mostRecentReceipt, null).isExpired() == Result.True) { finalProductDescriptions.Add(productDescription); } else { finalProductDescriptions.Add( new ProductDescription( productDescription.storeSpecificId, productDescription.metadata, appReceipt, mostRecentReceipt.transactionID)); UpdateAppleProductFields(productDescription.storeSpecificId, mostRecentReceipt.originalTransactionIdentifier, false); } } else if (productType == AppleStoreProductType.Consumable) { finalProductDescriptions.Add(productDescription); } else { finalProductDescriptions.Add( new ProductDescription( productDescription.storeSpecificId, productDescription.metadata, appReceipt, mostRecentReceipt.transactionID)); UpdateAppleProductFields(productDescription.storeSpecificId, mostRecentReceipt.originalTransactionIdentifier, false); } } } return finalProductDescriptions; } static AppleInAppPurchaseReceipt? FindMostRecentReceipt(AppleReceipt? appleReceipt, string productId) { if (appleReceipt == null) { return null; } var foundReceipts = Array.FindAll(appleReceipt.inAppPurchaseReceipts, (r) => r.productID == productId); Array.Sort(foundReceipts, (b, a) => a.purchaseDate.CompareTo(b.purchaseDate)); return FirstNonCancelledReceipt(foundReceipts); } static AppleInAppPurchaseReceipt? FirstNonCancelledReceipt(AppleInAppPurchaseReceipt[] foundReceipts) { foreach (var receipt in foundReceipts) { if (receipt.cancellationDate == DateTime.MinValue) { return receipt; } } return null; } [Obsolete("RestoreTransactions(Action callback) is deprecated, please use RestoreTransactions(Action callback) instead.")] public virtual void RestoreTransactions(Action? callback) { m_ObsoleteRestoreCallback = callback; m_Native?.RestoreTransactions(); } public virtual void RestoreTransactions(Action? callback) { m_RestoreCallback = callback; m_Native?.RestoreTransactions(); } public virtual void RefreshAppReceipt(Action successCallback, Action errorCallback) { m_RefreshReceiptSuccess = successCallback; m_RefreshReceiptError = errorCallback; m_Native?.RefreshAppReceipt(); } public virtual void RefreshAppReceipt(Action successCallback, Action errorCallback) { m_RefreshReceiptSuccess = successCallback; m_RefreshReceiptError = _ => { errorCallback(); }; m_Native?.RefreshAppReceipt(); } public void RegisterPurchaseDeferredListener(Action callback) { m_DeferredCallback = callback; } public void SetEntitlementsRevokedListener(Action> callback) { m_RevokedCallback = callback; } public virtual void ContinuePromotionalPurchases() { m_Native?.ContinuePromotionalPurchases(); } public Dictionary GetIntroductoryPriceDictionary() { return JSONSerializer.DeserializeSubscriptionDescriptions(m_ProductsJson); } public Dictionary GetProductDetails() { return JSONSerializer.DeserializeProductDetails(m_ProductsJson); } public virtual void PresentCodeRedemptionSheet() { m_Native?.PresentCodeRedemptionSheet(); } public void OnPurchaseDeferred(string productId) { if (null != m_DeferredCallback) { var product = unity.products.WithStoreSpecificID(productId); if (null != product) { m_DeferredCallback(product); } } } public void OnPromotionalPurchaseAttempted(string productId) { if (null != m_PromotionalPurchaseCallback) { var product = unity.products.WithStoreSpecificID(productId); if (null != product) { m_PromotionalPurchaseCallback(product); } } } public void OnTransactionsRestoredSuccess() { m_ObsoleteRestoreCallback?.Invoke(true); m_RestoreCallback?.Invoke(true, null); } public void OnTransactionsRestoredFail(string error) { m_ObsoleteRestoreCallback?.Invoke(false); m_RestoreCallback?.Invoke(false, error); } public void OnAppReceiptRetrieved(string receipt) { m_RefreshReceiptSuccess?.Invoke(receipt); } public void OnAppReceiptRefreshedFailed(string error) { m_RefreshReceiptError?.Invoke(error); } void OnEntitlementsRevoked(string productIds) { var revokedProducts = new List(); var appleReceipt = GetAppleReceiptFromBase64String(appReceipt); var productIdList = productIds.ArrayListFromJson(); foreach (string productId in productIdList) { var product = unity.products.WithStoreSpecificID(productId); if (null == product) { continue; } RevokeEntitlement(appleReceipt, productId, revokedProducts, product); } m_RevokedCallback?.Invoke(revokedProducts); } void RevokeEntitlement(AppleReceipt? appleReceipt, string productId, List revokedProducts, Product product) { if (HasInAppPurchaseReceipts(appleReceipt) && RestoreActiveEntitlement(appleReceipt!, productId)) { return; } revokedProducts.Add(product); PurchasingManager.OnEntitlementRevoked(product); } bool RestoreActiveEntitlement(AppleReceipt appleReceipt, string productId) { var receipt = FindMostRecentReceipt(appleReceipt, productId); if (receipt != null) { UpdateAppleProductFields(productId, receipt.originalTransactionIdentifier, true); unity.OnPurchaseSucceeded(productId, appReceipt, receipt.transactionID); return true; } return false; } public void OnFetchStorePromotionOrderSucceeded(string productIds) { if (null != m_FetchStorePromotionOrderSuccess) { var productIdList = productIds.ArrayListFromJson(); var products = new List(); foreach (var productId in productIdList) { var product = unity.products.WithStoreSpecificID(productId as string); products.Add(product); } m_FetchStorePromotionOrderSuccess(products); } } public void OnFetchStorePromotionOrderFailed() { m_FetchStorePromotionOrderError?.Invoke(); } public void OnFetchStorePromotionVisibilitySucceeded(String result) { if (null != m_FetchStorePromotionVisibilitySuccess) { var resultDictionary = ( Json.Deserialize(result) as Dictionary )?.ToDictionary(k => k.Key, k => k.Value.ToString()); var productId = resultDictionary?["productId"]; var storePromotionVisibility = resultDictionary?["visibility"]; Enum.TryParse(storePromotionVisibility, out AppleStorePromotionVisibility visibility); if (productId != null) { m_FetchStorePromotionVisibilitySuccess(productId, visibility); } } } public void OnFetchStorePromotionVisibilityFailed() { m_FetchStorePromotionVisibilityError?.Invoke(); } [MonoPInvokeCallback(typeof(UnityPurchasingCallback))] private static void MessageCallback(string subject, string payload, string receipt, string transactionId, string originalTransactionId, bool isRestored) { s_Util?.RunOnMainThread(() => { s_Instance?.ProcessMessage(subject, payload, receipt, transactionId, originalTransactionId, isRestored); }); } void ProcessMessage(string subject, string payload, string receipt, string transactionId, string originalTransactionId, bool isRestored) { if (string.IsNullOrEmpty(receipt)) { receipt = appReceipt ?? ""; } switch (subject) { case "OnSetupFailed": OnSetupFailed(payload); break; case "OnProductsRetrieved": OnProductsRetrieved(payload); break; case "OnPurchaseSucceeded": OnPurchaseSucceeded(payload, receipt, transactionId, originalTransactionId, isRestored); break; case "OnPurchaseFailed": OnPurchaseFailed(payload); break; case "onProductPurchaseDeferred": OnPurchaseDeferred(payload); break; case "onPromotionalPurchaseAttempted": OnPromotionalPurchaseAttempted(payload); break; case "onFetchStorePromotionOrderSucceeded": OnFetchStorePromotionOrderSucceeded(payload); break; case "onFetchStorePromotionOrderFailed": OnFetchStorePromotionOrderFailed(); break; case "onFetchStorePromotionVisibilitySucceeded": OnFetchStorePromotionVisibilitySucceeded(payload); break; case "onFetchStorePromotionVisibilityFailed": OnFetchStorePromotionVisibilityFailed(); break; case "onTransactionsRestoredSuccess": OnTransactionsRestoredSuccess(); break; case "onTransactionsRestoredFail": OnTransactionsRestoredFail(payload); break; case "onAppReceiptRefreshed": OnAppReceiptRetrieved(payload); break; case "onAppReceiptRefreshFailed": OnAppReceiptRefreshedFailed(payload); break; case "onEntitlementsRevoked": OnEntitlementsRevoked(payload); break; } } public void OnPurchaseSucceeded(string id, string receipt, string transactionId, string originalTransactionId, bool isRestored) { var appleReceipt = GetAppleReceiptFromBase64String(receipt); var mostRecentReceipt = FindMostRecentReceipt(appleReceipt, id); if (IsValidPurchaseState(mostRecentReceipt, id)) { isRestored = isRestored || IsRestored(id, mostRecentReceipt, transactionId, originalTransactionId); UpdateAppleProductFields(id, originalTransactionId, isRestored); base.OnPurchaseSucceeded(id, receipt, transactionId); } else { base.FinishTransaction(null, transactionId); } } AppleReceipt? GetAppleReceiptFromBase64String(string? receipt) { AppleReceipt? appleReceipt = null; if (!string.IsNullOrEmpty(receipt)) { var parser = new AppleReceiptParser(); try { appleReceipt = parser.Parse(Convert.FromBase64String(receipt)); } catch (Exception ex) { m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.ParseReceiptTransactionError, ex); } } return appleReceipt; } static bool IsValidPurchaseState(AppleInAppPurchaseReceipt? mostRecentReceipt, string productId) { var isValid = true; if (mostRecentReceipt != null) { var productType = (AppleStoreProductType)Enum.Parse(typeof(AppleStoreProductType), mostRecentReceipt.productType.ToString()); if (productType == AppleStoreProductType.AutoRenewingSubscription) { // if the product is auto-renewing subscription, check if this transaction is expired if (new SubscriptionInfo(mostRecentReceipt, null).isExpired() == Result.True) { isValid = false; } } } return isValid; } bool IsRestored(string productId, AppleInAppPurchaseReceipt? productReceipt, string transactionId, string originalTransactionId) { bool isRestored; var currentProduct = unity.products.WithStoreSpecificID(productId); if (currentProduct == null) { isRestored = false; } else { isRestored = currentProduct.definition.type == ProductType.Subscription ? IsSubscriptionRestored(productReceipt, currentProduct) : IsNonSubscriptionRestored(transactionId, originalTransactionId); } return isRestored; } static bool IsSubscriptionRestored(AppleInAppPurchaseReceipt? productReceipt, Product previousProduct) { var isRestored = false; if (previousProduct.hasReceipt) { var subscriptionExpirationDate = productReceipt?.subscriptionExpirationDate; var subscriptionManager = new SubscriptionManager(previousProduct, null); var previousSubscriptionInfo = subscriptionManager.getSubscriptionInfo(); if (previousSubscriptionInfo != null && previousSubscriptionInfo.isCancelled() == Result.False && previousSubscriptionInfo.getExpireDate() >= subscriptionExpirationDate) { isRestored = true; } } return isRestored; } static bool IsNonSubscriptionRestored(string transactionId, string? originalTransactionId) { return originalTransactionId != null && originalTransactionId != transactionId; } void UpdateAppleProductFields(string id, string originalTransactionId, bool isRestored) { var product = unity.products.WithStoreSpecificID(id); if (product != null) { product.appleProductIsRestored = isRestored; product.appleOriginalTransactionID = originalTransactionId; } } } }