From 365f1e008bad7f72a981aa9149e8fb3f4a44059c Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Wed, 13 Apr 2022 00:00:00 +0000 Subject: [PATCH 01/12] com.unity.purchasing@4.2.0-pre.1 ## [4.2.0-pre.1] - 2022-04-13 ### Added - Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction). - The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package. ### Changed - The minimum Unity Editor version supported is 2020.3. - The In-App Purchasing service window now links to the [new Unity Dashboard](https://dashboard.unity3d.com/) for Unity Editors 2022 and up. ### Fixed - GooglePlay - Fixed OnInitializeFailed never called if GooglePlay BillingClient is not ready during initialization. - GooglePlay - GoogleBilling is allowed to initialize correctly even if the user's Google account is logged out, so long as it is linked. The user will need to log in to their account to continue making purchases. - Fixed a build error `DirectoryNotFoundException` that occurred when the build platform was iOS or tvOS and the build target was another platform. --- CHANGELOG.md | 15 ++++ Documentation~/TableOfContents.md | 1 + .../UnityIAPInitializeUnityGamingServices.md | 37 +++++++++ ...tyIAPInitializeUnityGamingServices.md.meta | 3 + Documentation~/UnityIAPValidatingReceipts.md | 2 + Editor/AppleCapabilities.cs | 5 +- .../PurchasingGameService.cs | 3 +- Plugins/UnityPurchasing/iOS/UnityPurchasing.m | 12 +-- .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes Runtime/Common/Purchasing.Common.asmdef | 2 + Runtime/Purchasing/Analytics.meta | 3 + .../{ => Analytics}/AnalyticsReporter.cs | 12 +-- .../{ => Analytics}/AnalyticsReporter.cs.meta | 0 .../Analytics/EmptyUnityAnalytics.cs | 20 +++++ .../Analytics/EmptyUnityAnalytics.cs.meta | 11 +++ .../Purchasing/Analytics/IUnityAnalytics.cs | 13 ++++ .../{ => Analytics}/IUnityAnalytics.cs.meta | 0 Runtime/Purchasing/Analytics/Models.meta | 3 + .../Models/AnalyticsTransactionReceipt.cs | 12 +++ .../AnalyticsTransactionReceipt.cs.meta | 3 + .../Analytics/Models/GoogleReceipt.cs | 11 +++ .../Analytics/Models/GoogleReceipt.cs.meta | 3 + .../Purchasing/Analytics/UnityAnalytics.cs | 71 ++++++++++++++++++ .../{ => Analytics}/UnityAnalytics.cs.meta | 0 Runtime/Purchasing/IUnityAnalytics.cs | 15 ---- Runtime/Purchasing/PurchasingManager.cs | 5 +- Runtime/Purchasing/Telemetry.meta | 3 + Runtime/Purchasing/Telemetry/Diagnostics.meta | 3 + .../Telemetry/Diagnostics/Interfaces.meta | 3 + .../Interfaces/ITelemetryDiagnostics.cs | 9 +++ .../Interfaces/ITelemetryDiagnostics.cs.meta | 3 + .../ITelemetryDiagnosticsInstanceWrapper.cs | 11 +++ .../Diagnostics/TelemetryDiagnosticNames.cs | 8 ++ .../TelemetryDiagnosticNames.cs.meta | 3 + .../Diagnostics/TelemetryDiagnosticParams.cs | 13 ++++ .../TelemetryDiagnosticParams.cs.meta | 3 + .../Diagnostics/TelemetryDiagnostics.cs | 19 +++++ .../Diagnostics/TelemetryDiagnostics.cs.meta | 3 + .../TelemetryDiagnosticsInstanceWrapper.cs | 40 ++++++++++ ...elemetryDiagnosticsInstanceWrapper.cs.meta | 3 + Runtime/Purchasing/Telemetry/Metrics.meta | 3 + .../Telemetry/Metrics/Interfaces.meta | 3 + .../Interfaces/ITelemetryMetricEvent.cs | 8 ++ .../Interfaces/ITelemetryMetricEvent.cs.meta | 3 + .../Metrics/Interfaces/ITelemetryMetrics.cs | 7 ++ .../Interfaces/ITelemetryMetrics.cs.meta | 3 + .../ITelemetryMetricsInstanceWrapper.cs | 11 +++ .../ITelemetryMetricsInstanceWrapper.cs.meta | 3 + .../Telemetry/Metrics/TelemetryMetricEvent.cs | 52 +++++++++++++ .../Metrics/TelemetryMetricEvent.cs.meta | 3 + .../Telemetry/Metrics/TelemetryMetricNames.cs | 21 ++++++ .../Metrics/TelemetryMetricNames.cs.meta | 3 + .../Metrics/TelemetryMetricParams.cs | 15 ++++ .../Metrics/TelemetryMetricParams.cs.meta | 3 + .../Telemetry/Metrics/TelemetryMetricTypes.cs | 9 +++ .../Metrics/TelemetryMetricTypes.cs.meta | 3 + .../Telemetry/Metrics/TelemetryMetrics.cs | 21 ++++++ .../Metrics/TelemetryMetrics.cs.meta | 3 + .../TelemetryMetricsInstanceWrapper.cs | 54 +++++++++++++ .../TelemetryMetricsInstanceWrapper.cs.meta | 3 + .../Purchasing/Telemetry/TelemetryQueue.cs | 42 +++++++++++ .../Telemetry/TelemetryQueue.cs.meta | 3 + .../Purchasing/UnifiedReceiptExtensions.cs | 53 +++++++++++++ .../UnifiedReceiptExtensions.cs.meta | 3 + Runtime/Purchasing/UnityAnalytics.cs | 26 ------- .../Purchasing/UnityEngine.Purchasing.asmdef | 6 +- Runtime/Purchasing/UnityPurchasing.cs | 5 +- .../GooglePlay/AAR/GooglePlayStoreService.cs | 61 ++++++++++++--- .../AAR/Interfaces/IGoogleBillingClient.cs | 1 + .../AAR/Interfaces/IGooglePlayStoreService.cs | 3 +- .../AAR/Models/GoogleBillingClient.cs | 5 ++ .../GoogleRetrieveProductsFailureReason.cs | 8 ++ ...oogleRetrieveProductsFailureReason.cs.meta | 11 +++ .../AAR/Models/ProductDescriptionQuery.cs | 4 +- .../Android/GooglePlay/GooglePlayStore.cs | 14 ++-- .../GooglePlay/GooglePlayStoreExtensions.cs | 14 +++- .../GooglePlayStoreRetrieveProductsService.cs | 62 ++++++++++----- .../Stores/AppleAppStore/AppleStoreImpl.cs | 38 +++++++++- Runtime/Stores/BaseStore/JSONStore.cs | 14 +++- Runtime/Stores/FakeStore/FakeStore.cs | 2 +- Runtime/Stores/StandardPurchasingModule.cs | 34 +++++++-- Runtime/Stores/Telemetry.meta | 3 + .../Telemetry/IapCoreInitializeCallback.cs | 39 ++++++++++ .../IapCoreInitializeCallback.cs.meta | 3 + .../UnityEngine.Purchasing.Stores.asmdef | 3 +- package.json | 15 ++-- 86 files changed, 966 insertions(+), 123 deletions(-) create mode 100644 Documentation~/UnityIAPInitializeUnityGamingServices.md create mode 100644 Documentation~/UnityIAPInitializeUnityGamingServices.md.meta create mode 100644 Runtime/Purchasing/Analytics.meta rename Runtime/Purchasing/{ => Analytics}/AnalyticsReporter.cs (73%) rename Runtime/Purchasing/{ => Analytics}/AnalyticsReporter.cs.meta (100%) create mode 100644 Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs create mode 100644 Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta create mode 100644 Runtime/Purchasing/Analytics/IUnityAnalytics.cs rename Runtime/Purchasing/{ => Analytics}/IUnityAnalytics.cs.meta (100%) create mode 100644 Runtime/Purchasing/Analytics/Models.meta create mode 100644 Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs create mode 100644 Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs create mode 100644 Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs.meta create mode 100644 Runtime/Purchasing/Analytics/UnityAnalytics.cs rename Runtime/Purchasing/{ => Analytics}/UnityAnalytics.cs.meta (100%) delete mode 100644 Runtime/Purchasing/IUnityAnalytics.cs create mode 100644 Runtime/Purchasing/Telemetry.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/Interfaces.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnosticsInstanceWrapper.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs create mode 100644 Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/TelemetryQueue.cs create mode 100644 Runtime/Purchasing/Telemetry/TelemetryQueue.cs.meta create mode 100644 Runtime/Purchasing/UnifiedReceiptExtensions.cs create mode 100644 Runtime/Purchasing/UnifiedReceiptExtensions.cs.meta delete mode 100644 Runtime/Purchasing/UnityAnalytics.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs.meta create mode 100644 Runtime/Stores/Telemetry.meta create mode 100644 Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs create mode 100644 Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcdbbb..5481bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [4.2.0-pre.1] - 2022-04-13 + +### Added +- Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction). +- The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package. + +### Changed +- The minimum Unity Editor version supported is 2020.3. +- The In-App Purchasing service window now links to the [new Unity Dashboard](https://dashboard.unity3d.com/) for Unity Editors 2022 and up. + +### Fixed +- GooglePlay - Fixed OnInitializeFailed never called if GooglePlay BillingClient is not ready during initialization. +- GooglePlay - GoogleBilling is allowed to initialize correctly even if the user's Google account is logged out, so long as it is linked. The user will need to log in to their account to continue making purchases. +- Fixed a build error `DirectoryNotFoundException` that occurred when the build platform was iOS or tvOS and the build target was another platform. + ## [4.1.4] - 2022-03-30 ### Fixed diff --git a/Documentation~/TableOfContents.md b/Documentation~/TableOfContents.md index 1ad4f90..7f7893e 100644 --- a/Documentation~/TableOfContents.md +++ b/Documentation~/TableOfContents.md @@ -9,6 +9,7 @@ * [Coded](DefiningProductsCoded.md) * [IAP Catalog](UnityIAPDefiningProducts.md) * [Initialize IAP](UnityIAPInitialization.md) + * [Initialize Unity Gaming Services](UnityIAPInitializeUnityGamingServices.md) * [Fetching Additional Products](UnityIAPFetchingProductsIncrementally.md) * Creating a Purchasing Button * [Browsing Product Metadata](UnityIAPBrowsingMetadata.md) diff --git a/Documentation~/UnityIAPInitializeUnityGamingServices.md b/Documentation~/UnityIAPInitializeUnityGamingServices.md new file mode 100644 index 0000000..5f5dd79 --- /dev/null +++ b/Documentation~/UnityIAPInitializeUnityGamingServices.md @@ -0,0 +1,37 @@ +### Initialize Unity Gaming Services +Call `UnityServices.InitializeAsync()` to initialize all Unity Gaming Services at once. +It returns a `Task` that enables you to monitor the initialization's progression. + +#### Example +```cs +using System; +using Unity.Services.Core; +using Unity.Services.Core.Environments; +using UnityEngine; + +public class InitializeUnityServices : MonoBehaviour +{ + public string environment = "production"; + + async void Start() + { + try + { + var options = new InitializationOptions() + .SetEnvironmentName(environment); + + await UnityServices.InitializeAsync(options); + } + catch (Exception exception) + { + // An error occured during services initialization. + } + } +} +``` + +## Technical details + +The `InitializeAsync` methods affect the currently installed service packages in your Unity project. + +Note that this method is not supported during edit time. diff --git a/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta b/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta new file mode 100644 index 0000000..b1e2cf7 --- /dev/null +++ b/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 60c3a76fd50646498ce7b03defa9439c +timeCreated: 1649358944 \ No newline at end of file diff --git a/Documentation~/UnityIAPValidatingReceipts.md b/Documentation~/UnityIAPValidatingReceipts.md index 5c7a038..a579d20 100644 --- a/Documentation~/UnityIAPValidatingReceipts.md +++ b/Documentation~/UnityIAPValidatingReceipts.md @@ -32,6 +32,8 @@ The `CrossPlatformValidator` performs two checks: Note that the validator only validates receipts generated on Google Play and Apple platforms. Receipts generated on any other platform, including fakes generated in the Editor, throw an __IAPSecurityException__. +Be sure that your `CrossPlatformValidator` object has been created in time for processing your purchases. Note that during the initialization of Unity IAP, it is possible that pending purchases from previous sessions may be fetched from the store and processed. If you are using a persistent object of this type, create it before initializing Unity IAP. + If you try to validate a receipt for a platform that you haven't supplied a secret key for, a __MissingStoreSecretException__ is thrown. ```` diff --git a/Editor/AppleCapabilities.cs b/Editor/AppleCapabilities.cs index 9207d73..311fbc2 100644 --- a/Editor/AppleCapabilities.cs +++ b/Editor/AppleCapabilities.cs @@ -14,7 +14,10 @@ class AppleCapabilities : IPostprocessBuildWithReport public void OnPostprocessBuild(BuildReport report) { - OnPostprocessBuild(report.summary.platform, report.summary.outputPath); + if (report.summary.platform == BuildTarget.tvOS || report.summary.platform == BuildTarget.iOS) + { + OnPostprocessBuild(report.summary.platform, report.summary.outputPath); + } } static void OnPostprocessBuild(BuildTarget buildTarget, string path) diff --git a/Editor/ServiceProjectSettings/PurchasingGameService.cs b/Editor/ServiceProjectSettings/PurchasingGameService.cs index 3d58bb2..467766b 100644 --- a/Editor/ServiceProjectSettings/PurchasingGameService.cs +++ b/Editor/ServiceProjectSettings/PurchasingGameService.cs @@ -1,6 +1,7 @@ #if SERVICES_SDK_CORE_ENABLED using System; using Unity.Services.Core.Editor; +using Unity.Services.Core.Editor.OrganizationHandler; using UnityEngine; namespace UnityEditor.Purchasing @@ -46,7 +47,7 @@ public void RemoveDisableAction(Action toRemove) public string GetFormattedDashboardUrl() { - return $"https://analytics.cloud.unity3d.com/projects/{CloudProjectSettings.projectId}/purchasing/"; + return $"https://dashboard.unity3d.com/organizations/{OrganizationProvider.Organization.Key}/projects/{CloudProjectSettings.projectId}/analytics/v2/dashboards/revenue"; } public IEditorGameServiceEnabler Enabler => m_Enabler; diff --git a/Plugins/UnityPurchasing/iOS/UnityPurchasing.m b/Plugins/UnityPurchasing/iOS/UnityPurchasing.m index 8e889b2..c656356 100644 --- a/Plugins/UnityPurchasing/iOS/UnityPurchasing.m +++ b/Plugins/UnityPurchasing/iOS/UnityPurchasing.m @@ -393,7 +393,7 @@ - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)tran { UnityPurchasingLog(@"UpdatedTransactions"); for(SKPaymentTransaction *transaction in transactions) { - + [self handleTransaction:transaction]; } } @@ -425,9 +425,9 @@ - (void) handleTransaction:(SKPaymentTransaction *) transaction if (transaction.payment.productIdentifier == nil) { return; } - + SKProduct* product = [validProducts objectForKey:transaction.payment.productIdentifier]; - + switch (transaction.transactionState) { case SKPaymentTransactionStatePurchasing: @@ -450,7 +450,7 @@ - (void) handleTransaction:(SKPaymentTransaction *) transaction - (void) handleTransactionPurchased:(SKPaymentTransaction*) transaction forProduct:(SKProduct*) product { - + #if MAC_APPSTORE // There is no transactionReceipt on Mac NSString* receipt = @""; @@ -460,7 +460,7 @@ - (void) handleTransactionPurchased:(SKPaymentTransaction*) transaction forProdu #endif transactionReceipts[transaction.payment.productIdentifier] = receipt; - + if (product != nil) { [self onTransactionSucceeded:transaction]; } @@ -565,7 +565,7 @@ - (void)fetchStorePromotionVisibilityForProduct:(NSString*)productId [self UnitySendMessage:@"onFetchStorePromotionVisibilityFailed" payload:nil]; } else { NSString *visibility = [UnityPurchasing getStringForStorePromotionVisibility: storePromotionVisibility]; - + UnityPurchasingLog(@"Fetched Store Promotion Visibility for %@", product.productIdentifier); NSString *payload = [UnityPurchasing serializeVisibilityResultForProduct:productId withVisiblity:visibility]; diff --git a/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing b/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing index 5b8d6499cc3f394c8a22f72e2814907b2b4a28f6..b68265fcb7d20708956d42ef160172f8f9c16896 100644 GIT binary patch delta 187 zcmbQxCp4i?s9_6ZLlU$6zPRnpNsPUWV3u$SD`|rqT;#J zcW##yWID;Luxmoe;lqMgQ#UT+Z`2DHH{j=NdCndBa!JUx6Z+w~?^)ZKM47fTi89L_ z0Gp?Ll{pq-a`QE2zEqK|_JT=jMo-;DZ!-U@JXQYey1BT-_NCS=H<=X*6Q!=V71+GW j&ur!qp2@NK=l1JIC$Co$e)~f*ZP_MQ;qCM6S&G~NR)J0J delta 187 zcmbQxCp4i?s9_6ZLlX1qsF3Z=NsPUW%xi!w;S|PCYebr2{@HRoH55Ais-?4$CG$d1 zDD#xOu@IA+uQBtbir7YPsMw-^s>|f-f0GkzdmY6Z9Hq7|wPv}=tnk_LW?#UK le;H@HnQgiFZV0(J@EF8d98y&)xT2G2=l*H?JbRWRcK|0$OgaDn diff --git a/Runtime/Common/Purchasing.Common.asmdef b/Runtime/Common/Purchasing.Common.asmdef index 573d258..327cee5 100644 --- a/Runtime/Common/Purchasing.Common.asmdef +++ b/Runtime/Common/Purchasing.Common.asmdef @@ -1,6 +1,8 @@ { "name": "Purchasing.Common", "references": [ + "Unity.Services.Core", + "Unity.Services.Core.Internal" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Runtime/Purchasing/Analytics.meta b/Runtime/Purchasing/Analytics.meta new file mode 100644 index 0000000..7ffcc2c --- /dev/null +++ b/Runtime/Purchasing/Analytics.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e7da53e7495b4ac4b8f79193b783a410 +timeCreated: 1631295645 \ No newline at end of file diff --git a/Runtime/Purchasing/AnalyticsReporter.cs b/Runtime/Purchasing/Analytics/AnalyticsReporter.cs similarity index 73% rename from Runtime/Purchasing/AnalyticsReporter.cs rename to Runtime/Purchasing/Analytics/AnalyticsReporter.cs index b605bd5..7006bad 100644 --- a/Runtime/Purchasing/AnalyticsReporter.cs +++ b/Runtime/Purchasing/Analytics/AnalyticsReporter.cs @@ -8,9 +8,9 @@ namespace UnityEngine.Purchasing /// Responsible for adapting Unity Purchasing's unified /// receipts for Unity Analytics' Transaction API. /// - internal class AnalyticsReporter + class AnalyticsReporter { - private IUnityAnalytics m_Analytics; + IUnityAnalytics m_Analytics; public AnalyticsReporter(IUnityAnalytics analytics) { @@ -24,11 +24,7 @@ public void OnPurchaseSucceeded(Product product) return; } - m_Analytics.Transaction(product.definition.storeSpecificId, - product.metadata.localizedPrice, - product.metadata.isoCurrencyCode, - product.receipt, - null); + m_Analytics.SendTransactionEvent(product); } public void OnPurchaseFailed(Product product, PurchaseFailureReason reason) @@ -39,7 +35,7 @@ public void OnPurchaseFailed(Product product, PurchaseFailureReason reason) { "price", product.metadata.localizedPrice }, { "currency", product.metadata.isoCurrencyCode } }; - m_Analytics.CustomEvent("unity.PurchaseFailed", data); + m_Analytics.SendCustomEvent("unity.PurchaseFailed", data); } } } diff --git a/Runtime/Purchasing/AnalyticsReporter.cs.meta b/Runtime/Purchasing/Analytics/AnalyticsReporter.cs.meta similarity index 100% rename from Runtime/Purchasing/AnalyticsReporter.cs.meta rename to Runtime/Purchasing/Analytics/AnalyticsReporter.cs.meta diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs b/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs new file mode 100644 index 0000000..0b7f59e --- /dev/null +++ b/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace UnityEngine.Purchasing +{ + /// + /// Forward transaction information to Unity Analytics. + /// + class EmptyUnityAnalytics : IUnityAnalytics + { + public void SendTransactionEvent(Product product) + { + } + + public void SendCustomEvent(string name, Dictionary data) + { + } + } +} diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta new file mode 100644 index 0000000..2becb3e --- /dev/null +++ b/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 588a7493bf6bcb64eb06d3444a15a5a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Purchasing/Analytics/IUnityAnalytics.cs b/Runtime/Purchasing/Analytics/IUnityAnalytics.cs new file mode 100644 index 0000000..b3d7208 --- /dev/null +++ b/Runtime/Purchasing/Analytics/IUnityAnalytics.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace UnityEngine.Purchasing +{ + /// + /// Extracted from Unity Analytics for testability. + /// + interface IUnityAnalytics + { + void SendTransactionEvent(Product product); + void SendCustomEvent(string name, Dictionary data); + } +} diff --git a/Runtime/Purchasing/IUnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta similarity index 100% rename from Runtime/Purchasing/IUnityAnalytics.cs.meta rename to Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta diff --git a/Runtime/Purchasing/Analytics/Models.meta b/Runtime/Purchasing/Analytics/Models.meta new file mode 100644 index 0000000..383709d --- /dev/null +++ b/Runtime/Purchasing/Analytics/Models.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fe0cf112f44a4a41a35d890ef9a3aa1b +timeCreated: 1631295629 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs b/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs new file mode 100644 index 0000000..a9477e2 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs @@ -0,0 +1,12 @@ +using System; +using Unity.Services.Analytics; + +namespace UnityEngine.Purchasing +{ + class AnalyticsTransactionReceipt + { + public string transactionReceipt { get; set; } + public string transactionReceiptSignature { get; set; } + public TransactionServer? transactionServer { get; set; } + } +} diff --git a/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs.meta b/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs.meta new file mode 100644 index 0000000..ab20487 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Models/AnalyticsTransactionReceipt.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e10f9fcbd028472a860c00cc2acd39e2 +timeCreated: 1631295615 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs b/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs new file mode 100644 index 0000000..5eaceb2 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs @@ -0,0 +1,11 @@ +using System; + +namespace UnityEngine.Purchasing +{ + [Serializable] + class GoogleReceipt + { + public string json; + public string signature; + } +} diff --git a/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs.meta b/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs.meta new file mode 100644 index 0000000..e5af32f --- /dev/null +++ b/Runtime/Purchasing/Analytics/Models/GoogleReceipt.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 418f497ff2764085950acd83caecbf54 +timeCreated: 1631296841 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/UnityAnalytics.cs b/Runtime/Purchasing/Analytics/UnityAnalytics.cs new file mode 100644 index 0000000..bb6c7c4 --- /dev/null +++ b/Runtime/Purchasing/Analytics/UnityAnalytics.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using Unity.Services.Analytics; +using UnityEngine; + +namespace UnityEngine.Purchasing +{ + /// + /// Forward transaction information to Unity Analytics. + /// + class UnityAnalytics : IUnityAnalytics + { + public void SendTransactionEvent(Product product) + { +#if ENABLE_CLOUD_SERVICES_ANALYTICS + Analytics.Analytics.Transaction(product.definition.storeSpecificId, + product.metadata.localizedPrice, + product.metadata.isoCurrencyCode, + product.receipt, + null); +#endif + var unifiedReceipt = JsonUtility.FromJson(product.receipt); + var analyticsReceipt = unifiedReceipt.ToReceiptAndSignature(); + var txParams = BuildTransactionParameters(product, unifiedReceipt, analyticsReceipt); + + AnalyticsService.Instance.Transaction(txParams); + } + + public void SendCustomEvent(string name, Dictionary data) + { +#if ENABLE_CLOUD_SERVICES_ANALYTICS + Analytics.Analytics.CustomEvent(name, data); +#endif + } + + static TransactionParameters BuildTransactionParameters(Product product, UnifiedReceipt unifiedReceipt, AnalyticsTransactionReceipt analyticsReceipt) + { + var txParams = new TransactionParameters + { + ProductID = product.definition.storeSpecificId, + TransactionName = product.metadata.localizedTitle, + TransactionID = unifiedReceipt.TransactionID, + TransactionType = TransactionType.PURCHASE, + TransactionReceipt = analyticsReceipt.transactionReceipt, + TransactionReceiptSignature = analyticsReceipt.transactionReceiptSignature, + TransactionServer = analyticsReceipt.transactionServer, + ProductsReceived = new Unity.Services.Analytics.Product + { + Items = new List + { + new Item + { + ItemName = product.definition.id, + ItemType = product.definition.type.ToString(), + ItemAmount = 1 + } + } + }, + ProductsSpent = new Unity.Services.Analytics.Product + { + RealCurrency = new RealCurrency + { + RealCurrencyType = product.metadata.isoCurrencyCode, + RealCurrencyAmount = AnalyticsService.Instance.ConvertCurrencyToMinorUnits(product.metadata.isoCurrencyCode, (double)product.metadata.localizedPrice) + } + } + }; + return txParams; + } + } +} diff --git a/Runtime/Purchasing/UnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta similarity index 100% rename from Runtime/Purchasing/UnityAnalytics.cs.meta rename to Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta diff --git a/Runtime/Purchasing/IUnityAnalytics.cs b/Runtime/Purchasing/IUnityAnalytics.cs deleted file mode 100644 index 27372d5..0000000 --- a/Runtime/Purchasing/IUnityAnalytics.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace UnityEngine.Purchasing -{ - /// - /// Extracted from Unity Analytics for testability. - /// - internal interface IUnityAnalytics - { - void Transaction(string productId, decimal price, - string currency, string receipt, - string signature); - void CustomEvent(string name, Dictionary data); - } -} diff --git a/Runtime/Purchasing/PurchasingManager.cs b/Runtime/Purchasing/PurchasingManager.cs index baa8f66..88a1983 100644 --- a/Runtime/Purchasing/PurchasingManager.cs +++ b/Runtime/Purchasing/PurchasingManager.cs @@ -154,11 +154,12 @@ public void OnSetupFailed(InitializationFailureReason reason) { if (initialized) { - if (null != m_AdditionalProductsFailCallback) - m_AdditionalProductsFailCallback(reason); + m_AdditionalProductsFailCallback?.Invoke(reason); } else + { m_Listener.OnInitializeFailed(reason); + } } public void OnPurchaseFailed(PurchaseFailureDescription description) diff --git a/Runtime/Purchasing/Telemetry.meta b/Runtime/Purchasing/Telemetry.meta new file mode 100644 index 0000000..d7794e3 --- /dev/null +++ b/Runtime/Purchasing/Telemetry.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e2f852bb976845d7b2762fa0f9f48a38 +timeCreated: 1646925157 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics.meta b/Runtime/Purchasing/Telemetry/Diagnostics.meta new file mode 100644 index 0000000..b214fcc --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4c159872b38344158729ea584238d6e7 +timeCreated: 1648146479 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces.meta b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces.meta new file mode 100644 index 0000000..f0e7b18 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e0a86a11952f4a8fbefd35ee2c6f769f +timeCreated: 1648211783 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs new file mode 100644 index 0000000..823ced8 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs @@ -0,0 +1,9 @@ +using System; + +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryDiagnostics + { + void SendDiagnostic(string diagnosticName, Exception e); + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs.meta b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs.meta new file mode 100644 index 0000000..f142117 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnostics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 74c11f3a05d74a6b9cd665300bd0de76 +timeCreated: 1647626278 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnosticsInstanceWrapper.cs b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnosticsInstanceWrapper.cs new file mode 100644 index 0000000..93c9d6f --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/Interfaces/ITelemetryDiagnosticsInstanceWrapper.cs @@ -0,0 +1,11 @@ +using Unity.Services.Core.Telemetry.Internal; + +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryDiagnosticsInstanceWrapper + { + void SetDiagnosticsInstance(IDiagnostics diagnosticsInstance); + + void SendDiagnostic(string diagnosticName, string diagnosticException); + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs new file mode 100644 index 0000000..2c25241 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs @@ -0,0 +1,8 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + static class TelemetryDiagnosticNames + { + internal const string ParseReceiptTransactionError = "parse_receipt_transaction_error"; + internal const string InvalidProductError = "invalid_product_error"; + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs.meta b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs.meta new file mode 100644 index 0000000..eded3a3 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 677134de48484d52b61f6fa75a593203 +timeCreated: 1648135802 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs new file mode 100644 index 0000000..d57e40d --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs @@ -0,0 +1,13 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + struct TelemetryDiagnosticParams + { + internal string name; + internal string exception; + internal TelemetryDiagnosticParams(string diagnosticName, string diagnosticException) + { + name = diagnosticName; + exception = diagnosticException; + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs.meta b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs.meta new file mode 100644 index 0000000..1214119 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticParams.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0edab4879bac4c7b8de2a01221f8ff67 +timeCreated: 1648146780 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs new file mode 100644 index 0000000..4fe0209 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs @@ -0,0 +1,19 @@ +using System; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryDiagnostics : ITelemetryDiagnostics + { + ITelemetryDiagnosticsInstanceWrapper m_TelemetryDiagnosticsInstanceWrapper; + + public TelemetryDiagnostics(ITelemetryDiagnosticsInstanceWrapper telemetryDiagnosticsInstanceWrapper) + { + m_TelemetryDiagnosticsInstanceWrapper = telemetryDiagnosticsInstanceWrapper; + } + + public void SendDiagnostic(string diagnosticName, Exception e) + { + m_TelemetryDiagnosticsInstanceWrapper.SendDiagnostic(diagnosticName, e.ToString()); + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs.meta b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs.meta new file mode 100644 index 0000000..8b881bb --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnostics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a84a2636a2b84429b495cd03f0cc97cd +timeCreated: 1647634374 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs new file mode 100644 index 0000000..ca9d74a --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs @@ -0,0 +1,40 @@ +using System; +using Unity.Services.Core.Telemetry.Internal; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryDiagnosticsInstanceWrapper : ITelemetryDiagnosticsInstanceWrapper + { + IDiagnostics m_Instance; + TelemetryQueue m_Queue; + + public TelemetryDiagnosticsInstanceWrapper() + { + m_Queue = new TelemetryQueue(SendDiagnostic); + } + + public void SetDiagnosticsInstance(IDiagnostics diagnosticsInstance) + { + m_Instance = diagnosticsInstance; + m_Queue.SendQueuedEvents(); + } + + public void SendDiagnostic(string diagnosticName, string diagnosticException) + { + var diagnosticParams = new TelemetryDiagnosticParams(diagnosticName, diagnosticException); + if (m_Instance != null) + { + SendDiagnostic(diagnosticParams); + } + else + { + m_Queue.QueueEvent(diagnosticParams); + } + } + + void SendDiagnostic(TelemetryDiagnosticParams diagnosticParams) + { + m_Instance.SendDiagnostic(diagnosticParams.name, diagnosticParams.exception); + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs.meta b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs.meta new file mode 100644 index 0000000..886eaff --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticsInstanceWrapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 781dae170fdb496ca6d777c6708c5a49 +timeCreated: 1646925183 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics.meta b/Runtime/Purchasing/Telemetry/Metrics.meta new file mode 100644 index 0000000..500f143 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2f6cffa6702349b8bc20b2986d3145e2 +timeCreated: 1648146365 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces.meta b/Runtime/Purchasing/Telemetry/Metrics/Interfaces.meta new file mode 100644 index 0000000..5dc4590 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 738fadef27b946418f067db7de94dfa0 +timeCreated: 1648211801 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs new file mode 100644 index 0000000..a6aaeed --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs @@ -0,0 +1,8 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryMetricEvent + { + void StartMetric(); + void StopAndSendMetric(); + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs.meta new file mode 100644 index 0000000..a43d87d --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricEvent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 786cf30712f4445386ccba873bb3a98f +timeCreated: 1648137121 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs new file mode 100644 index 0000000..62cc675 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs @@ -0,0 +1,7 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryMetrics + { + ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricTypes metricType, string metricName); + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta new file mode 100644 index 0000000..d5a6242 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: da5d2bfd2a2a4216b3f406aaddd38045 +timeCreated: 1647637930 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs new file mode 100644 index 0000000..c496bdb --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs @@ -0,0 +1,11 @@ +using Unity.Services.Core.Telemetry.Internal; + +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryMetricsInstanceWrapper + { + void SetMetricsInstance(IMetrics metricsInstance); + + void SendMetric(TelemetryMetricTypes telemetryMetricTypes, string metricName, double metricTimeSeconds); + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs.meta new file mode 100644 index 0000000..c28a7ba --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsInstanceWrapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5fe2ab0e885547a6baf44dd58fe17faf +timeCreated: 1648061730 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs new file mode 100644 index 0000000..73715e7 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs @@ -0,0 +1,52 @@ +using System; +using System.Diagnostics; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryMetricEvent : ITelemetryMetricEvent + { + ITelemetryMetricsInstanceWrapper m_TelemetryMetricsInstanceWrapper; + TelemetryMetricTypes m_MetricType; + string m_MetricName; + Stopwatch m_Stopwatch = new Stopwatch(); + + internal TelemetryMetricEvent(ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper, TelemetryMetricTypes metricType, string metricName) + { + m_TelemetryMetricsInstanceWrapper = telemetryMetricsInstanceWrapper; + m_MetricType = metricType; + m_MetricName = metricName; + } + + public void StartMetric() + { + if (m_Stopwatch != null) + { + if (!m_Stopwatch.IsRunning) + { + m_Stopwatch.Start(); + } + else + { + throw new Exception("Metric was already started."); + } + } + else + { + throw new Exception("Metric was already sent."); + } + } + + public void StopAndSendMetric() + { + if (m_Stopwatch != null) + { + m_TelemetryMetricsInstanceWrapper?.SendMetric(m_MetricType, m_MetricName, m_Stopwatch.Elapsed.Seconds); + m_Stopwatch = null; + } + else + { + throw new Exception("Metric was already sent."); + } + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs.meta new file mode 100644 index 0000000..d2689f2 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricEvent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8e1a5986ed9d4d5483b3bf4c11ffc659 +timeCreated: 1648137154 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs new file mode 100644 index 0000000..dabccba --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs @@ -0,0 +1,21 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + static class TelemetryMetricNames + { + internal const string confirmSubscriptionPriceChangeName = "confirm_subscription_price_change"; + internal const string continuePromotionalPurchasesName = "continue_promotional_purchases"; + internal const string dequeueQueryProductsTimeName = "dequeue_query_products_time"; + internal const string dequeueQueryPurchasesTimeName = "dequeue_query_purchases_time"; + internal const string fetchStorePromotionOrderName = "fetch_store_promotion_order"; + internal const string fetchStorePromotionVisibilityName = "fetch_store_promotion_visibility"; + internal const string initPurchaseName = "init_purchase"; + internal const string packageInitTimeName = "package_init_time"; + internal const string presentCodeRedemptionSheetName = "present_code_redemption_sheet"; + internal const string refreshAppReceiptName = "refresh_app_receipt"; + internal const string restoreTransactionName = "restore_transaction"; + internal const string retrieveProductsName = "retrieve_products"; + internal const string setStorePromotionOrderName = "set_store_promotion_order"; + internal const string setStorePromotionVisibilityName = "set_store_promotion_visibility"; + internal const string upgradeDowngradeSubscriptionName = "upgrade_downgrade_subscription"; + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta new file mode 100644 index 0000000..51cff38 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d4c484718cfa4b7d9ae7ac7e00b6bee2 +timeCreated: 1648065355 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs new file mode 100644 index 0000000..8b0e969 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs @@ -0,0 +1,15 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + struct TelemetryMetricParams + { + internal TelemetryMetricTypes type; + internal string name; + internal double timeSeconds; + internal TelemetryMetricParams(TelemetryMetricTypes metricType, string metricName, double metricTimeSeconds) + { + type = metricType; + name = metricName; + timeSeconds = metricTimeSeconds; + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs.meta new file mode 100644 index 0000000..6dc1def --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricParams.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1950adf491c348aa9e84d45283c62684 +timeCreated: 1648147882 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs new file mode 100644 index 0000000..5a0568c --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs @@ -0,0 +1,9 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + enum TelemetryMetricTypes + { + Gauge = 0, + Sum = 1, + Histogram = 2, + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs.meta new file mode 100644 index 0000000..6e3db76 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricTypes.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2e36f73d272142cf816acf3ab69baead +timeCreated: 1647983238 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs new file mode 100644 index 0000000..3dbbe5d --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs @@ -0,0 +1,21 @@ +using System; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryMetrics : ITelemetryMetrics + { + ITelemetryMetricsInstanceWrapper m_TelemetryMetricsInstanceWrapper; + + public TelemetryMetrics(ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper) + { + m_TelemetryMetricsInstanceWrapper = telemetryMetricsInstanceWrapper; + } + + public ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricTypes metricType, string metricName) + { + ITelemetryMetricEvent metricEvent = new TelemetryMetricEvent(m_TelemetryMetricsInstanceWrapper, metricType, metricName); + metricEvent.StartMetric(); + return metricEvent; + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta new file mode 100644 index 0000000..541b10c --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: cea968401d6c428aa32bd7a4eee6bb7c +timeCreated: 1647638562 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs new file mode 100644 index 0000000..9054d78 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs @@ -0,0 +1,54 @@ +using System; +using Unity.Services.Core.Telemetry.Internal; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryMetricsInstanceWrapper : ITelemetryMetricsInstanceWrapper + { + IMetrics m_Instance; + TelemetryQueue m_Queue; + + public TelemetryMetricsInstanceWrapper() + { + m_Queue = new TelemetryQueue(SendMetricByType); + } + + public void SetMetricsInstance(IMetrics metricsInstance) + { + m_Instance = metricsInstance; + if (m_Instance != null) + { + m_Queue.SendQueuedEvents(); + } + } + + public void SendMetric(TelemetryMetricTypes metricType, string metricName, double metricTimeSeconds) + { + var telemetryMetricParams = new TelemetryMetricParams(metricType, metricName, metricTimeSeconds); + if (m_Instance != null) + { + SendMetricByType(telemetryMetricParams); + } + else + { + m_Queue.QueueEvent(telemetryMetricParams); + } + } + + void SendMetricByType(TelemetryMetricParams metricParams) + { + switch (metricParams.type) + { + case TelemetryMetricTypes.Gauge: + m_Instance.SendGaugeMetric(metricParams.name, metricParams.timeSeconds); + break; + case TelemetryMetricTypes.Histogram: + m_Instance.SendHistogramMetric(metricParams.name, metricParams.timeSeconds); + break; + case TelemetryMetricTypes.Sum: + m_Instance.SendSumMetric(metricParams.name, metricParams.timeSeconds); + break; + } + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs.meta new file mode 100644 index 0000000..e72cf1e --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsInstanceWrapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 757df1bf595d4524a56504167404cbd1 +timeCreated: 1646925175 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/TelemetryQueue.cs b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs new file mode 100644 index 0000000..af9b36f --- /dev/null +++ b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryQueue + { + Action m_SendTelemetryEvent; + Queue m_Queue; + internal const int k_maxQueueSize = 10; + + public TelemetryQueue(Action sendTelemetryEvent) + { + m_SendTelemetryEvent = sendTelemetryEvent; + } + + internal void QueueEvent(TTelemetryEventParams telemetryEvent) + { + m_Queue ??= new Queue(); + m_Queue.Enqueue(telemetryEvent); + + if (m_Queue.Count > k_maxQueueSize) + { + m_Queue.Dequeue(); + } + } + + internal void SendQueuedEvents() + { + if (m_SendTelemetryEvent == null || m_Queue == null) + { + return; + } + + foreach (var telemetryEvent in m_Queue) + { + m_SendTelemetryEvent(telemetryEvent); + } + m_Queue.Clear(); + } + } +} diff --git a/Runtime/Purchasing/Telemetry/TelemetryQueue.cs.meta b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs.meta new file mode 100644 index 0000000..fca4351 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eb92d6c4c6c34d95a4d860eb07ced031 +timeCreated: 1648146539 \ No newline at end of file diff --git a/Runtime/Purchasing/UnifiedReceiptExtensions.cs b/Runtime/Purchasing/UnifiedReceiptExtensions.cs new file mode 100644 index 0000000..0ec67a8 --- /dev/null +++ b/Runtime/Purchasing/UnifiedReceiptExtensions.cs @@ -0,0 +1,53 @@ +using System; +using Unity.Services.Analytics; +using UnityEngine; + +namespace UnityEngine.Purchasing +{ + static class UnifiedReceiptExtensions + { + public static AnalyticsTransactionReceipt ToReceiptAndSignature(this UnifiedReceipt receipt) + { + var analyticsReceipt = new AnalyticsTransactionReceipt(); + + analyticsReceipt.transactionServer = receipt.ToTransactionServer(); + + if (analyticsReceipt.transactionServer == TransactionServer.GOOGLE) + { + var googleReceipt = JsonUtility.FromJson(receipt.Payload); + + analyticsReceipt.transactionReceipt = googleReceipt?.json; + analyticsReceipt.transactionReceiptSignature = googleReceipt?.signature; + } + else + { + analyticsReceipt.transactionReceipt = receipt.Payload; + } + + analyticsReceipt.transactionReceipt = EscapeEmbeddedQuotationMarks(analyticsReceipt.transactionReceipt); + + return analyticsReceipt; + } + + static TransactionServer? ToTransactionServer(this UnifiedReceipt receipt) + { + if (receipt.Store == null) + return null; + + var store = receipt.Store.ToLower(); + + if (store.Contains("mac") || store.Contains("apple")) return TransactionServer.APPLE; + + if (store.Contains("google")) return TransactionServer.GOOGLE; + + if (store.Contains("amazon")) return TransactionServer.AMAZON; + + return null; + } + + static string EscapeEmbeddedQuotationMarks(string receipt) + { + return receipt?.Replace("\"", "\\\""); + } + } +} diff --git a/Runtime/Purchasing/UnifiedReceiptExtensions.cs.meta b/Runtime/Purchasing/UnifiedReceiptExtensions.cs.meta new file mode 100644 index 0000000..95284ca --- /dev/null +++ b/Runtime/Purchasing/UnifiedReceiptExtensions.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bc9f787fffce4c72a4db42835e20e089 +timeCreated: 1631241369 \ No newline at end of file diff --git a/Runtime/Purchasing/UnityAnalytics.cs b/Runtime/Purchasing/UnityAnalytics.cs deleted file mode 100644 index a310aab..0000000 --- a/Runtime/Purchasing/UnityAnalytics.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; - -namespace UnityEngine.Purchasing -{ - /// - /// Forward transaction information to Unity Analytics. - /// - internal class UnityAnalytics : IUnityAnalytics - { - public void Transaction(string productId, decimal price, string currency, string receipt, string signature) - { -#if ENABLE_CLOUD_SERVICES_ANALYTICS - Analytics.Analytics.Transaction(productId, price, currency, receipt, signature, true); -#endif - } - - public void CustomEvent(string name, Dictionary data) - { -#if ENABLE_CLOUD_SERVICES_ANALYTICS - Analytics.Analytics.CustomEvent(name, data); -#endif - } - } -} diff --git a/Runtime/Purchasing/UnityEngine.Purchasing.asmdef b/Runtime/Purchasing/UnityEngine.Purchasing.asmdef index 4a6e679..966da08 100644 --- a/Runtime/Purchasing/UnityEngine.Purchasing.asmdef +++ b/Runtime/Purchasing/UnityEngine.Purchasing.asmdef @@ -1,7 +1,11 @@ { "name": "UnityEngine.Purchasing", "references": [ - "Purchasing.Common" + "Purchasing.Common", + "Unity.Services.Core", + "Unity.Services.Core.Environments", + "Unity.Services.Core.Internal", + "Unity.Services.Analytics" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Runtime/Purchasing/UnityPurchasing.cs b/Runtime/Purchasing/UnityPurchasing.cs index a1a98d2..9059d3b 100644 --- a/Runtime/Purchasing/UnityPurchasing.cs +++ b/Runtime/Purchasing/UnityPurchasing.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing @@ -17,7 +16,11 @@ public abstract class UnityPurchasing /// The ConfigurationBuilder containing the product definitions mapped to stores public static void Initialize(IStoreListener listener, ConfigurationBuilder builder) { +#if DISABLE_RUNTIME_IAP_ANALYTICS + Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, new EmptyUnityAnalytics(), builder.factory.GetCatalogProvider()); +#else Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, new UnityAnalytics(), builder.factory.GetCatalogProvider()); +#endif } /// diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs index fff9843..09e4c2d 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs @@ -4,6 +4,7 @@ using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Telemetry; namespace UnityEngine.Purchasing { @@ -24,6 +25,7 @@ class GooglePlayStoreService : IGooglePlayStoreService IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; IGooglePriceChangeService m_GooglePriceChangeService; IGoogleLastKnownProductService m_GoogleLastKnownProductService; + ITelemetryMetrics m_TelemetryMetrics; internal GooglePlayStoreService( IGoogleBillingClient billingClient, @@ -33,7 +35,8 @@ internal GooglePlayStoreService( IGoogleQueryPurchasesService queryPurchasesService, IBillingClientStateListener billingClientStateListener, IGooglePriceChangeService priceChangeService, - IGoogleLastKnownProductService lastKnownProductService) + IGoogleLastKnownProductService lastKnownProductService, + ITelemetryMetrics telemetryMetrics) { m_BillingClient = billingClient; m_QuerySkuDetailsService = querySkuDetailsService; @@ -43,6 +46,7 @@ internal GooglePlayStoreService( m_GooglePriceChangeService = priceChangeService; m_GoogleLastKnownProductService = lastKnownProductService; m_BillingClientStateListener = billingClientStateListener; + m_TelemetryMetrics = telemetryMetrics; InitConnectionWithGooglePlay(); } @@ -70,16 +74,23 @@ public void ResumeConnection() } } + public bool IsConnectionReady() + { + return m_BillingClient.IsReady(); + } + void OnConnected() { m_GoogleConnectionState = GoogleBillingConnectionState.Connected; m_CurrentConnectionAttempts = 0; + DequeueQueryProducts(); DequeueFetchPurchases(); } void DequeueQueryProducts() { + var dequeueQueryProductsMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.dequeueQueryProductsTimeName); var productsFailedToDequeue = new Queue(); var stop = false; @@ -96,7 +107,8 @@ void DequeueQueryProducts() case GoogleBillingConnectionState.Disconnected: { var productDescriptionQuery = m_ProductsToQuery.Dequeue(); - productDescriptionQuery.onRetrieveProductsFailed(); + var reason = AreConnectionAttemptsExhausted() ? GoogleRetrieveProductsFailureReason.BillingServiceUnavailable : GoogleRetrieveProductsFailureReason.BillingServiceDisconnected; + productDescriptionQuery.onRetrieveProductsFailed(reason); productsFailedToDequeue.Enqueue(productDescriptionQuery); break; @@ -120,15 +132,18 @@ void DequeueQueryProducts() { m_ProductsToQuery.Enqueue(product); } + dequeueQueryProductsMetric.StopAndSendMetric(); } void DequeueFetchPurchases() { + var dequeueQueryPurchasesMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.dequeueQueryPurchasesTimeName); while (m_OnPurchaseSucceededQueue.Count > 0) { var onPurchaseSucceed = m_OnPurchaseSucceededQueue.Dequeue(); FetchPurchases(onPurchaseSucceed); } + dequeueQueryPurchasesMetric.StopAndSendMetric(); } void OnDisconnected() @@ -140,26 +155,50 @@ void OnDisconnected() void AttemptReconnection() { - if (m_CurrentConnectionAttempts < k_MaxConnectionAttempts) + if (!AreConnectionAttemptsExhausted()) { StartConnection(); } + else + { + OnReconnectionFailure(); + } + } + + bool AreConnectionAttemptsExhausted() + { + return m_CurrentConnectionAttempts >= k_MaxConnectionAttempts; + } + + void OnReconnectionFailure() + { + m_GoogleConnectionState = GoogleBillingConnectionState.Disconnected; + DequeueQueryProducts(); } - public void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductFailed) + public void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) { + var retrieveProductsMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.retrieveProductsName); if (m_GoogleConnectionState == GoogleBillingConnectionState.Connected) { m_QuerySkuDetailsService.QueryAsyncSku(products, onProductsReceived); } else { - if (m_GoogleConnectionState == GoogleBillingConnectionState.Disconnected) - { - onRetrieveProductFailed(); - } - m_ProductsToQuery.Enqueue(new ProductDescriptionQuery(products, onProductsReceived, onRetrieveProductFailed)); + HandleRetrieveProductsNotConnected(products, onProductsReceived, onRetrieveProductsFailed); + } + retrieveProductsMetric.StopAndSendMetric(); + } + + void HandleRetrieveProductsNotConnected(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) + { + if (m_GoogleConnectionState == GoogleBillingConnectionState.Disconnected) + { + var reason = AreConnectionAttemptsExhausted() ? GoogleRetrieveProductsFailureReason.BillingServiceUnavailable : GoogleRetrieveProductsFailureReason.BillingServiceDisconnected; + onRetrieveProductsFailed(reason); } + + m_ProductsToQuery.Enqueue(new ProductDescriptionQuery(products, onProductsReceived, onRetrieveProductsFailed)); } public void Purchase(ProductDefinition product) @@ -169,9 +208,11 @@ public void Purchase(ProductDefinition product) public void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) { + var initPurchaseMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.initPurchaseName); m_GoogleLastKnownProductService.SetLastKnownProductId(product.storeSpecificId); m_GoogleLastKnownProductService.SetLastKnownProrationMode(desiredProrationMode); m_GooglePurchaseService.Purchase(product, oldProduct, desiredProrationMode); + initPurchaseMetric.StopAndSendMetric(); } public void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge) @@ -203,7 +244,9 @@ public void SetObfuscatedProfileId(string obfuscatedProfileId) public void ConfirmSubscriptionPriceChange(ProductDefinition product, Action onPriceChangeAction) { + var confirmSubscriptionPriceChangeMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.confirmSubscriptionPriceChangeName); m_GooglePriceChangeService.PriceChange(product, onPriceChangeAction); + confirmSubscriptionPriceChangeMetric.StopAndSendMetric(); } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs index 8c23537..11f152f 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs @@ -8,6 +8,7 @@ interface IGoogleBillingClient { void StartConnection(IBillingClientStateListener billingClientStateListener); void EndConnection(); + bool IsReady(); AndroidJavaObject QueryPurchase(string skuType); void QuerySkuDetailsAsync(List skus, string type, Action> onSkuDetailsResponseAction); AndroidJavaObject LaunchBillingFlow(AndroidJavaObject sku, string oldSku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs index cd33355..d20906f 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs @@ -9,7 +9,7 @@ namespace UnityEngine.Purchasing.Interfaces { interface IGooglePlayStoreService { - void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductFailed); + void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductFailed); void Purchase(ProductDefinition product); void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode); void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge); @@ -18,5 +18,6 @@ interface IGooglePlayStoreService void SetObfuscatedProfileId(string obfuscatedProfileId); void ConfirmSubscriptionPriceChange(ProductDefinition product, Action onPriceChangeAction); void ResumeConnection(); + bool IsConnectionReady(); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs index 807ec02..091d17a 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs @@ -86,6 +86,11 @@ public void EndConnection() m_BillingClient.Call("endConnection"); } + public bool IsReady() + { + return m_BillingClient.Call("isReady"); + } + public AndroidJavaObject QueryPurchase(string skuType) { return m_BillingClient.Call("queryPurchases", skuType); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs new file mode 100644 index 0000000..785d348 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs @@ -0,0 +1,8 @@ +namespace UnityEngine.Purchasing.Models +{ + enum GoogleRetrieveProductsFailureReason + { + BillingServiceDisconnected, + BillingServiceUnavailable + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs.meta new file mode 100644 index 0000000..c4f564a --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleRetrieveProductsFailureReason.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de8718d9dee8b914081d6939956948af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/ProductDescriptionQuery.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/ProductDescriptionQuery.cs index fda8bd7..f32623e 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/ProductDescriptionQuery.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/ProductDescriptionQuery.cs @@ -10,9 +10,9 @@ class ProductDescriptionQuery { internal ReadOnlyCollection products; internal Action> onProductsReceived; - internal Action onRetrieveProductsFailed; + internal Action onRetrieveProductsFailed; - internal ProductDescriptionQuery(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) + internal ProductDescriptionQuery(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) { this.products = products; this.onProductsReceived = onProductsReceived; diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs index 9992662..c09c3e7 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs @@ -16,7 +16,6 @@ class GooglePlayStore: AbstractStore IGooglePlayStoreExtensionsInternal m_GooglePlayStoreExtensions; IGooglePlayConfigurationInternal m_GooglePlayConfigurationInternal; IUtil m_Util; - bool m_HasInitiallyRetrievedProducts; public GooglePlayStore(IGooglePlayStoreRetrieveProductsService retrieveProductsService, IGooglePlayStorePurchaseService storePurchaseService, @@ -49,8 +48,6 @@ public override void Initialize(IStoreCallback callback) m_FinishTransactionService.SetStoreCallback(scriptingStoreCallback); m_GooglePurchaseCallback.SetStoreCallback(scriptingStoreCallback); m_GooglePlayStoreExtensions.SetStoreCallback(scriptingStoreCallback); - - m_HasInitiallyRetrievedProducts = false; } /// @@ -64,14 +61,17 @@ public override void RetrieveProducts(ReadOnlyCollection prod m_RetrieveProductsService.RetrieveProducts(products, shouldFetchPurchases); } + bool HasInitiallyRetrievedProducts() + { + return m_RetrieveProductsService.HasInitiallyRetrievedProducts(); + } + bool ShouldFetchPurchasesNext() { var shouldFetchPurchases = true; - - if (!m_HasInitiallyRetrievedProducts) - { - m_HasInitiallyRetrievedProducts = true; + if (!HasInitiallyRetrievedProducts()) + { shouldFetchPurchases = !m_GooglePlayConfigurationInternal.IsFetchPurchasesAtInitializeSkipped(); } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs index 6430d44..ac1eb50 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs @@ -3,6 +3,7 @@ using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Telemetry; namespace UnityEngine.Purchasing { @@ -10,14 +11,18 @@ class GooglePlayStoreExtensions: IGooglePlayStoreExtensions, IGooglePlayStoreExt { IGooglePlayStoreService m_GooglePlayStoreService; IGooglePlayStoreFinishTransactionService m_GooglePlayStoreFinishTransactionService; + ITelemetryDiagnostics m_TelemetryDiagnostics; + ITelemetryMetrics m_TelemetryMetrics; IStoreCallback m_StoreCallback; Action m_DeferredPurchaseAction; Action m_DeferredProrationUpgradeDowngradeSubscriptionAction; - internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService) + internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) { m_GooglePlayStoreService = googlePlayStoreService; m_GooglePlayStoreFinishTransactionService = googlePlayStoreFinishTransactionService; + m_TelemetryDiagnostics = telemetryDiagnostics; + m_TelemetryMetrics = telemetryMetrics; } public void UpgradeDowngradeSubscription(string oldSku, string newSku) @@ -32,6 +37,7 @@ public void UpgradeDowngradeSubscription(string oldSku, string newSku, int desir public void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePlayProrationMode desiredProrationMode) { + var upgradeDowngradeSubscriptionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.upgradeDowngradeSubscriptionName); Product product = m_StoreCallback.FindProductById(newSku); Product oldProduct = m_StoreCallback.FindProductById(oldSku); if (product != null && product.definition.type == ProductType.Subscription && @@ -47,10 +53,12 @@ public void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePla PurchaseFailureReason.ProductUnavailable, "Please verify that the products are subscriptions and are not null.")); } + upgradeDowngradeSubscriptionMetric.StopAndSendMetric(); } public void RestoreTransactions(Action callback) { + var restoreTransactionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.restoreTransactionName); m_GooglePlayStoreService.FetchPurchases(purchase => { if (purchase != null) @@ -58,6 +66,7 @@ public void RestoreTransactions(Action callback) callback(true); } }); + restoreTransactionMetric.StopAndSendMetric(); } public void FinishAdditionalTransaction(string productId, string transactionId) @@ -108,8 +117,9 @@ public bool IsPurchasedProductDeferred(Product product) //PurchaseState codes: https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState#pending return purchaseState == 2 || purchaseState == 4; } - catch + catch (Exception ex) { + m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.ParseReceiptTransactionError, ex); Debug.LogWarning("Cannot parse Google receipt for transaction " + product.transactionID); return false; } diff --git a/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreRetrieveProductsService.cs b/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreRetrieveProductsService.cs index a76f4dc..e4b1a58 100644 --- a/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreRetrieveProductsService.cs +++ b/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreRetrieveProductsService.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing { @@ -11,12 +13,15 @@ class GooglePlayStoreRetrieveProductsService : IGooglePlayStoreRetrieveProductsS IGoogleFetchPurchases m_GoogleFetchPurchases; IStoreCallback m_StoreCallback; IGooglePlayConfigurationInternal m_GooglePlayConfigurationInternal; + bool m_HasInitiallyRetrievedProducts; internal GooglePlayStoreRetrieveProductsService(IGooglePlayStoreService googlePlayStoreService, IGoogleFetchPurchases googleFetchPurchases, IGooglePlayConfigurationInternal googlePlayConfigurationInternal) { m_GooglePlayStoreService = googlePlayStoreService; m_GoogleFetchPurchases = googleFetchPurchases; m_GooglePlayConfigurationInternal = googlePlayConfigurationInternal; + + m_HasInitiallyRetrievedProducts = false; } public void SetStoreCallback(IStoreCallback storeCallback) @@ -26,26 +31,40 @@ public void SetStoreCallback(IStoreCallback storeCallback) public void RetrieveProducts(ReadOnlyCollection products, bool wantPurchases = true) { - if (m_StoreCallback != null) + if (wantPurchases) { - m_GooglePlayStoreService.RetrieveProducts(products, retrievedProducts => - { - if (wantPurchases) - { - m_GoogleFetchPurchases.FetchPurchases(purchaseProducts => - { - var mergedProducts = MakePurchasesIntoProducts(retrievedProducts, purchaseProducts); - m_StoreCallback.OnProductsRetrieved(mergedProducts); - }); - } - else - { - m_StoreCallback.OnProductsRetrieved(retrievedProducts); - } - }, () => - { - m_GooglePlayConfigurationInternal.NotifyInitializationConnectionFailed(); - }); + m_GooglePlayStoreService.RetrieveProducts(products, OnProductsRetrievedWithPurchaseFetch, OnRetrieveProductsFailed); + } + else + { + m_GooglePlayStoreService.RetrieveProducts(products, OnProductsRetrieved, OnRetrieveProductsFailed); + } + } + + void OnProductsRetrievedWithPurchaseFetch(List retrievedProducts) + { + m_HasInitiallyRetrievedProducts = true; + + m_GoogleFetchPurchases.FetchPurchases(purchaseProducts => + { + var mergedProducts = MakePurchasesIntoProducts(retrievedProducts, purchaseProducts); + m_StoreCallback?.OnProductsRetrieved(mergedProducts); + }); + } + + void OnProductsRetrieved(List retrievedProducts) + { + m_HasInitiallyRetrievedProducts = true; + + m_StoreCallback?.OnProductsRetrieved(retrievedProducts); + } + + void OnRetrieveProductsFailed(GoogleRetrieveProductsFailureReason reason) + { + if (reason == GoogleRetrieveProductsFailureReason.BillingServiceUnavailable && !m_HasInitiallyRetrievedProducts) + { + m_GooglePlayConfigurationInternal.NotifyInitializationConnectionFailed(); + m_StoreCallback.OnSetupFailed(InitializationFailureReason.PurchasingUnavailable); } } @@ -71,5 +90,10 @@ static List MakePurchasesIntoProducts(List m_FetchStorePromotionVisibilitySuccess; private INativeAppleStore m_Native; + ITelemetryDiagnostics m_TelemetryDiagnostics; + ITelemetryMetrics m_TelemetryMetrics; private static IUtil util; private static AppleStoreImpl instance; @@ -32,13 +35,16 @@ internal class AppleStoreImpl : JSONStore, IAppleExtensions, IAppleConfiguration private string products_json; - public AppleStoreImpl(IUtil util) { + public AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) { AppleStoreImpl.util = util; instance = this; + m_TelemetryDiagnostics = telemetryDiagnostics; + m_TelemetryMetrics = telemetryMetrics; } public void SetNativeStore(INativeAppleStore apple) { base.SetNativeStore (apple); + base.SetTelemetryMetrics(m_TelemetryMetrics); this.m_Native = apple; apple.SetUnityPurchasingCallback (MessageCallback); } @@ -71,22 +77,27 @@ public bool simulateAskToBuy { public void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) { + var fetchStorePromotionOrderMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.fetchStorePromotionOrderName); m_FetchStorePromotionOrderError = errorCallback; m_FetchStorePromotionOrderSuccess = successCallback; m_Native.FetchStorePromotionOrder(); + fetchStorePromotionOrderMetric.StopAndSendMetric(); } public void FetchStorePromotionVisibility(Product product, Action successCallback, Action errorCallback) { + var fetchStorePromotionVisibilityMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.fetchStorePromotionVisibilityName); m_FetchStorePromotionVisibilityError = errorCallback; m_FetchStorePromotionVisibilitySuccess = successCallback; m_Native.FetchStorePromotionVisibility(product.definition.id); + fetchStorePromotionVisibilityMetric.StopAndSendMetric(); } public void SetStorePromotionOrder(List products) { + var setStorePromotionOrderMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.setStorePromotionOrderName); // Encode product list as a json doc containing an array of store-specific ids: // { "products": [ "ssid1", "ssid2" ] } var productIds = new List(); @@ -97,13 +108,20 @@ public void SetStorePromotionOrder(List products) } var dict = new Dictionary{ { "products", productIds } }; m_Native.SetStorePromotionOrder(MiniJson.JsonEncode(dict)); + setStorePromotionOrderMetric.StopAndSendMetric(); } public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisibility visibility) { + var setStorePromotionVisibilityMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.setStorePromotionVisibilityName); if (product == null) - throw new ArgumentNullException(nameof(product)); + { + var ex = new ArgumentNullException(nameof(product)); + m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.InvalidProductError, ex); + throw ex; + } m_Native.SetStorePromotionVisibility(product.definition.storeSpecificId, visibility.ToString()); + setStorePromotionVisibilityMetric.StopAndSendMetric(); } public string GetTransactionReceiptForProduct (Product product) { @@ -189,15 +207,19 @@ public override void OnProductsRetrieved (string json) public void RestoreTransactions(Action callback) { + var restoreTransactionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.restoreTransactionName); m_RestoreCallback = callback; m_Native.RestoreTransactions (); + restoreTransactionMetric.StopAndSendMetric(); } public void RefreshAppReceipt(Action successCallback, Action errorCallback) { + var refreshAppReceiptMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.refreshAppReceiptName); m_RefreshReceiptSuccess = successCallback; m_RefreshReceiptError = errorCallback; m_Native.RefreshAppReceipt (); + refreshAppReceiptMetric.StopAndSendMetric(); } public void RegisterPurchaseDeferredListener(Action callback) @@ -207,7 +229,9 @@ public void RegisterPurchaseDeferredListener(Action callback) public void ContinuePromotionalPurchases() { + var continuePromotionalPurchases = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.continuePromotionalPurchasesName); m_Native.ContinuePromotionalPurchases (); + continuePromotionalPurchases.StopAndSendMetric(); } public Dictionary GetIntroductoryPriceDictionary() { @@ -220,7 +244,9 @@ public Dictionary GetProductDetails() { public void PresentCodeRedemptionSheet() { + var presentCodeRedemptionSheet = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.presentCodeRedemptionSheetName); m_Native.PresentCodeRedemptionSheet(); + presentCodeRedemptionSheet.StopAndSendMetric(); } public void OnPurchaseDeferred(string productId) @@ -380,9 +406,13 @@ internal AppleReceipt getAppleReceiptFromBase64String(string receipt) { AppleReceipt appleReceipt = null; if (!string.IsNullOrEmpty(receipt)) { var parser = new AppleReceiptParser (); - try { + try + { appleReceipt = parser.Parse (Convert.FromBase64String (receipt)); - } catch (Exception) { + } + catch (Exception ex) + { + m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.ParseReceiptTransactionError, ex); } } return appleReceipt; diff --git a/Runtime/Stores/BaseStore/JSONStore.cs b/Runtime/Stores/BaseStore/JSONStore.cs index fa603d0..97072c3 100644 --- a/Runtime/Stores/BaseStore/JSONStore.cs +++ b/Runtime/Stores/BaseStore/JSONStore.cs @@ -4,6 +4,7 @@ using System.IO; using UnityEngine.Purchasing.Extension; using System.Text; +using UnityEngine.Purchasing.Telemetry; namespace UnityEngine.Purchasing { @@ -48,6 +49,7 @@ public Product[] storeCatalog { private INativeStore m_Store; private List m_StoreCatalog; private bool m_IsRefreshing; + ITelemetryMetrics m_TelemetryMetrics; private Action m_RefreshCallback; @@ -71,10 +73,16 @@ public JSONStore() { } - public void SetNativeStore(INativeStore native) { + public void SetNativeStore(INativeStore native) + { this.m_Store = native; } + public void SetTelemetryMetrics(ITelemetryMetrics telemetryMetrics) + { + m_TelemetryMetrics = telemetryMetrics; + } + void IStoreInternal.SetModule(StandardPurchasingModule module) { if(module == null) @@ -111,7 +119,9 @@ public override void Initialize (IStoreCallback callback) public override void RetrieveProducts (ReadOnlyCollection products) { + var retrieveProductsMetric = m_TelemetryMetrics?.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.retrieveProductsName); m_Store.RetrieveProducts(JSONSerializer.SerializeProductDefs(products)); + retrieveProductsMetric?.StopAndSendMetric(); } internal void ProcessManagedStoreResponse(List storeProducts) @@ -137,7 +147,9 @@ internal void ProcessManagedStoreResponse(List storeProducts) public override void Purchase (UnityEngine.Purchasing.ProductDefinition product, string developerPayload) { + var initPurchaseMetric = m_TelemetryMetrics?.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.initPurchaseName); m_Store.Purchase (JSONSerializer.SerializeProductDef (product), developerPayload); + initPurchaseMetric?.StopAndSendMetric(); } public override void FinishTransaction (UnityEngine.Purchasing.ProductDefinition product, string transactionId) diff --git a/Runtime/Stores/FakeStore/FakeStore.cs b/Runtime/Stores/FakeStore/FakeStore.cs index 10a1c9b..6ab00bd 100644 --- a/Runtime/Stores/FakeStore/FakeStore.cs +++ b/Runtime/Stores/FakeStore/FakeStore.cs @@ -140,7 +140,7 @@ void FakePurchase(ProductDefinition product, string developerPayload) { if (allow) { - base.OnPurchaseSucceeded(product.storeSpecificId, "{ \"this\" : \"is a fake receipt\" }", Guid.NewGuid().ToString()); + base.OnPurchaseSucceeded(product.storeSpecificId, "ThisIsFakeReceiptData", Guid.NewGuid().ToString()); } else { diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index 1395e5f..906a0c1 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -5,6 +5,7 @@ using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Telemetry; using UnityEngine.Purchasing.Utils; #if UNITY_PURCHASING_GPBL @@ -24,7 +25,7 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS /// [Obsolete("Not accurate. Use Version instead.", false)] public const string k_PackageVersion = "3.0.1"; - internal readonly string k_Version = "4.0.1"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. + internal readonly string k_Version = "4.2.0-pre.1"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. /// /// The version of com.unity.purchasing installed and the app was built using. /// @@ -37,6 +38,8 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS internal IUtil util { get; private set; } internal ILogger logger { get; private set; } internal StoreInstance storeInstance { get; private set; } + internal ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper { get; set; } + internal ITelemetryDiagnosticsInstanceWrapper telemetryDiagnosticsInstanceWrapper { get; set; } // Map Android store enums to their public names. // Necessary because store enum names and public names almost, but not quite, match. private static Dictionary AndroidStoreNameMap = new Dictionary () { @@ -58,7 +61,7 @@ internal StoreInstance (string name, IStore instance) } internal StandardPurchasingModule(IUtil util, ILogger logger, INativeStoreProvider nativeStoreProvider, - RuntimePlatform platform, AppStore android) + RuntimePlatform platform, AppStore android, ITelemetryDiagnosticsInstanceWrapper telemetryDiagnosticsInstanceWrapper, ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper) { this.util = util; this.logger = logger; @@ -67,6 +70,8 @@ internal StandardPurchasingModule(IUtil util, ILogger logger, INativeStoreProvid useFakeStoreUIMode = FakeStoreUIMode.Default; useFakeStoreAlways = false; m_AppStorePlatform = android; + this.telemetryDiagnosticsInstanceWrapper = telemetryDiagnosticsInstanceWrapper; + this.telemetryMetricsInstanceWrapper = telemetryMetricsInstanceWrapper; } /// @@ -141,7 +146,9 @@ public static StandardPurchasingModule Instance (AppStore androidStore) logger, new NativeStoreProvider (), Application.platform, - androidStore); + androidStore, + new TelemetryDiagnosticsInstanceWrapper(), + new TelemetryMetricsInstanceWrapper()); } return ModuleInstance; @@ -247,13 +254,17 @@ private IStore InstantiateGoogleStore() IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService = new GooglePlayStoreFinishTransactionService(googlePlayStoreService); IGoogleFetchPurchases googleFetchPurchases = new GoogleFetchPurchases(googlePlayStoreService, googlePlayStoreFinishTransactionService); var googlePlayConfiguration = BuildGooglePlayStoreConfiguration(googlePlayStoreService, googlePurchaseCallback); + var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); + var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); IGooglePlayStoreRetrieveProductsService googlePlayStoreRetrieveProductsService = new GooglePlayStoreRetrieveProductsService( googlePlayStoreService, googleFetchPurchases, googlePlayConfiguration); var googlePlayStoreExtensions = BuildGooglePlayStoreExtensions( googlePlayStoreService, - googlePlayStoreFinishTransactionService); + googlePlayStoreFinishTransactionService, + telemetryDiagnostics, + telemetryMetrics); GooglePlayStore googlePlayStore = new GooglePlayStore( googlePlayStoreRetrieveProductsService, @@ -275,9 +286,9 @@ void BindGoogleExtension(GooglePlayStoreExtensions googlePlayStoreExtensions) BindExtension(googlePlayStoreExtensions); } - static GooglePlayStoreExtensions BuildGooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService) + static GooglePlayStoreExtensions BuildGooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) { - GooglePlayStoreExtensions googlePlayStoreExtensions = new GooglePlayStoreExtensions(googlePlayStoreService, googlePlayStoreFinishTransactionService); + GooglePlayStoreExtensions googlePlayStoreExtensions = new GooglePlayStoreExtensions(googlePlayStoreService, googlePlayStoreFinishTransactionService, telemetryDiagnostics, telemetryMetrics); return googlePlayStoreExtensions; } @@ -308,6 +319,7 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g var finishTransactionService = new GoogleFinishTransactionService(googleBillingClient, queryPurchasesService); var billingClientStateListener = new BillingClientStateListener(); var priceChangeService = new GooglePriceChangeService(googleBillingClient, googleQuerySkuDetailsService); + var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); googlePurchaseUpdatedListener.SetGoogleQueryPurchaseService(queryPurchasesService); @@ -319,7 +331,8 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g queryPurchasesService, billingClientStateListener, priceChangeService, - googleLastKnownProductService + googleLastKnownProductService, + telemetryMetrics ); } @@ -334,7 +347,9 @@ private IStore InstantiateUDP() private IStore InstantiateAndroidHelper (JSONStore store) { + var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); store.SetNativeStore (GetAndroidNativeStore(store)); + store.SetTelemetryMetrics(telemetryMetrics); return store; } @@ -361,9 +376,12 @@ private IStore InstantiateGooglePlayBilling() private IStore InstantiateApple () { - var store = new AppleStoreImpl (util); + var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); + var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); + var store = new AppleStoreImpl (util, telemetryDiagnostics, telemetryMetrics); var appleBindings = m_NativeStoreProvider.GetStorekit (store); store.SetNativeStore (appleBindings); + store.SetTelemetryMetrics(telemetryMetrics); BindExtension (store); return store; } diff --git a/Runtime/Stores/Telemetry.meta b/Runtime/Stores/Telemetry.meta new file mode 100644 index 0000000..546be5d --- /dev/null +++ b/Runtime/Stores/Telemetry.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 291fc9da3d80411cbec0b36277bce386 +timeCreated: 1649192820 \ No newline at end of file diff --git a/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs new file mode 100644 index 0000000..ba1db5f --- /dev/null +++ b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Unity.Services.Core.Internal; +using Unity.Services.Core.Telemetry.Internal; +using UnityEngine.Purchasing.Telemetry; + +namespace UnityEngine.Purchasing.Registration +{ + class IapCoreInitializeCallback : IInitializablePackage + { + const string k_PurchasingPackageName = "com.unity.purchasing"; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + static void Register() + { + CoreRegistry.Instance.RegisterPackage(new IapCoreInitializeCallback()) + .DependsOn() + .DependsOn(); + } + + public Task Initialize(CoreRegistry registry) + { + var metricsInstanceWrapper = StandardPurchasingModule.Instance().telemetryMetricsInstanceWrapper; + + ITelemetryMetrics telemetryMetrics = new TelemetryMetrics(metricsInstanceWrapper); + var packageInitTimeMetric = telemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.packageInitTimeName); + + var diagnosticsInstanceWrapper = StandardPurchasingModule.Instance().telemetryDiagnosticsInstanceWrapper; + var diagnosticsFactory = CoreRegistry.Instance.GetServiceComponent(); + diagnosticsInstanceWrapper.SetDiagnosticsInstance(diagnosticsFactory.Create(k_PurchasingPackageName)); + + var metricsFactory = CoreRegistry.Instance.GetServiceComponent(); + metricsInstanceWrapper.SetMetricsInstance(metricsFactory.Create(k_PurchasingPackageName)); + + packageInitTimeMetric.StopAndSendMetric(); + + return Task.CompletedTask; + } + } +} diff --git a/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs.meta b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs.meta new file mode 100644 index 0000000..b3edf49 --- /dev/null +++ b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 83f92647bc99413c98c5e8946f8d4c60 +timeCreated: 1646837122 \ No newline at end of file diff --git a/Runtime/Stores/UnityEngine.Purchasing.Stores.asmdef b/Runtime/Stores/UnityEngine.Purchasing.Stores.asmdef index c4006f1..1e64a48 100644 --- a/Runtime/Stores/UnityEngine.Purchasing.Stores.asmdef +++ b/Runtime/Stores/UnityEngine.Purchasing.Stores.asmdef @@ -14,7 +14,8 @@ "UnityEngine.Purchasing.WinRT", "UnityEngine.Purchasing.WinRTCore", "UnityEngine.Purchasing.WinRTStub", - "Unity.Services.Core" + "Unity.Services.Core", + "Unity.Services.Core.Internal" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/package.json b/package.json index 3fbb568..c76add0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.unity.purchasing", "displayName": "In App Purchasing", - "unity": "2019.4", + "unity": "2020.3", "_upm": { "gameService": { "groupIndex": 4, @@ -14,7 +14,7 @@ "iOS" ] }, - "version": "4.1.4", + "version": "4.2.0-pre.1", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -22,7 +22,8 @@ "com.unity.modules.unitywebrequest": "1.0.0", "com.unity.modules.jsonserialize": "1.0.0", "com.unity.modules.androidjni": "1.0.0", - "com.unity.services.core": "1.0.1" + "com.unity.services.core": "1.3.1", + "com.unity.services.analytics": "4.0.0-pre.1" }, "keywords": [ "purchasing", @@ -32,18 +33,18 @@ "license": "Unity Companion Package License v1.0", "hideInEditor": false, "upm": { - "changelog": "### Fixed\n- GooglePlay - Fixed issue where if an app is backgrounded while a purchase is being processed, \nan `OnPurchaseFailed` would be called with the purchase failure reason `UserCancelled`, even if the purchase was successful." + "changelog": "### Added\n- Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction).\n- The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package.\n\n### Changed\n- The minimum Unity Editor version supported is 2020.3.\n- The In-App Purchasing service window now links to the [new Unity Dashboard](https://dashboard.unity3d.com/) for Unity Editors 2022 and up.\n\n### Fixed\n- GooglePlay - Fixed OnInitializeFailed never called if GooglePlay BillingClient is not ready during initialization.\n- GooglePlay - GoogleBilling is allowed to initialize correctly even if the user's Google account is logged out, so long as it is linked. The user will need to log in to their account to continue making purchases.\n- Fixed a build error `DirectoryNotFoundException` that occurred when the build platform was iOS or tvOS and the build target was another platform." }, "relatedPackages": { - "com.unity.purchasing.tests": "4.1.4" + "com.unity.purchasing.tests": "4.2.0-pre.1" }, "upmCi": { - "footprint": "ac1e0aec8721b92c8930e070637d8ef9b1f34811" + "footprint": "45c6cd469210712b24e54a933882ed9f294cee0f" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "25048fc7bf20fd8f0a1a548fc73d024ffa7b25fe" + "revision": "e75af968366fd36fded712b3cb4f41e83df8f5a7" }, "samples": [ { From fecff3f02c960aa7273a45838eca18e61054cac2 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Thu, 28 Apr 2022 00:00:00 +0000 Subject: [PATCH 02/12] com.unity.purchasing@4.2.0-pre.2 ## [4.2.0-pre.2] - 2022-04-28 ### Added - Support for Unity Analytics TransactionFailed event. - Sample showcasing how to initialize [Unity Gaming Services](https://unity.com/solutions/gaming-services) using the [Services Core API](https://docs.unity.com/ugs-overview/services-core-api.html) ### Changed - The Analytics notice in the In-App Purchasing service window has been removed for Unity Editors 2022 and up. --- CHANGELOG.md | 13 +- .../Entity/Consts/UIResourceUtils.cs | 1 - .../Presenter/BasePurchasingState.cs | 3 - .../Presenter/PurchasingDisabledState.cs | 5 - .../Presenter/PurchasingEnabledState.cs | 5 - .../UI/UXML/AnalyticsNotice.uxml | 11 - .../UI/UXML/AnalyticsNotice.uxml.meta | 10 - .../UI/Views/AnalyticsNoticeBlock.cs | 67 --- .../UI/Views/AnalyticsNoticeBlock.cs.meta | 11 - .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes .../Purchasing/Analytics/AnalyticsAdapter.cs | 94 ++++ .../Analytics/AnalyticsAdapter.cs.meta | 3 + .../Purchasing/Analytics/AnalyticsClient.cs | 33 ++ ...porter.cs.meta => AnalyticsClient.cs.meta} | 0 .../Purchasing/Analytics/AnalyticsReporter.cs | 41 -- .../Analytics/EmptyUnityAnalytics.cs | 20 - .../Analytics/EmptyUnityAnalyticsAdapter.cs | 15 + .../EmptyUnityAnalyticsAdapter.cs.meta | 3 + .../Purchasing/Analytics/IUnityAnalytics.cs | 13 - .../Analytics/IUnityAnalytics.cs.meta | 10 - Runtime/Purchasing/Analytics/Interfaces.meta | 3 + .../Analytics/Interfaces/IAnalyticsAdapter.cs | 8 + .../Interfaces/IAnalyticsAdapter.cs.meta | 3 + .../Analytics/Interfaces/IAnalyticsClient.cs | 8 + .../Interfaces/IAnalyticsClient.cs.meta | 3 + .../Analytics/Interfaces/Legacy.meta | 3 + .../Legacy/ILegacyUnityAnalytics.cs | 13 + .../Legacy/ILegacyUnityAnalytics.cs.meta | 3 + Runtime/Purchasing/Analytics/Legacy.meta | 3 + .../Legacy/LegacyAnalyticsAdapter.cs | 35 ++ .../Legacy/LegacyAnalyticsAdapter.cs.meta | 3 + .../Analytics/Legacy/LegacyUnityAnalytics.cs | 20 + .../Legacy/LegacyUnityAnalytics.cs.meta | 3 + .../Purchasing/Analytics/UnityAnalytics.cs | 71 --- .../Analytics/UnityAnalytics.cs.meta | 10 - Runtime/Purchasing/StoreListenerProxy.cs | 4 +- .../Metrics/Interfaces/ITelemetryMetrics.cs | 7 - .../Interfaces/ITelemetryMetricsService.cs | 10 + ....meta => ITelemetryMetricsService.cs.meta} | 0 .../Metrics/TelemetryMetricDefinition.cs | 17 + .../Metrics/TelemetryMetricDefinition.cs.meta | 3 + .../Metrics/TelemetryMetricDefinitions.cs | 22 + ...eta => TelemetryMetricDefinitions.cs.meta} | 0 .../Telemetry/Metrics/TelemetryMetricNames.cs | 21 - .../Telemetry/Metrics/TelemetryMetrics.cs | 21 - .../Metrics/TelemetryMetricsService.cs | 29 + ...s.meta => TelemetryMetricsService.cs.meta} | 0 Runtime/Purchasing/UnityPurchasing.cs | 27 +- .../GooglePlay/AAR/GooglePlayStoreService.cs | 25 +- .../AAR/MetricizedGooglePlayStoreService.cs | 70 +++ .../MetricizedGooglePlayStoreService.cs.meta | 3 + .../GooglePlay/GooglePlayStoreExtensions.cs | 12 +- .../MetricizedGooglePlayStoreExtensions.cs | 34 ++ ...etricizedGooglePlayStoreExtensions.cs.meta | 3 + .../Stores/AppleAppStore/AppleStoreImpl.cs | 35 +- .../AppleAppStore/MetricizedAppleStoreImpl.cs | 78 +++ .../MetricizedAppleStoreImpl.cs.meta | 3 + Runtime/Stores/BaseStore/JSONStore.cs | 10 - .../Stores/BaseStore/MetricizedJsonStore.cs | 29 + .../BaseStore/MetricizedJsonStore.cs.meta} | 2 +- Runtime/Stores/StandardPurchasingModule.cs | 24 +- .../Telemetry/IapCoreInitializeCallback.cs | 19 +- .../README.md | 2 +- Samples~/06 InitializeGamingServices.meta | 3 + .../InitializeGamingServices.cs | 62 +++ .../InitializeGamingServices.cs.meta | 3 + .../InitializeGamingServices.unity | 498 ++++++++++++++++++ .../InitializeGamingServices.unity.meta | 7 + .../06 InitializeGamingServices/README.md | 6 + .../README.md.meta | 3 + package.json | 24 +- 71 files changed, 1217 insertions(+), 446 deletions(-) delete mode 100644 Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml delete mode 100644 Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml.meta delete mode 100644 Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs delete mode 100644 Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs.meta create mode 100644 Runtime/Purchasing/Analytics/AnalyticsAdapter.cs create mode 100644 Runtime/Purchasing/Analytics/AnalyticsAdapter.cs.meta create mode 100644 Runtime/Purchasing/Analytics/AnalyticsClient.cs rename Runtime/Purchasing/Analytics/{AnalyticsReporter.cs.meta => AnalyticsClient.cs.meta} (100%) delete mode 100644 Runtime/Purchasing/Analytics/AnalyticsReporter.cs delete mode 100644 Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs create mode 100644 Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs create mode 100644 Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs.meta delete mode 100644 Runtime/Purchasing/Analytics/IUnityAnalytics.cs delete mode 100644 Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Interfaces.meta create mode 100644 Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs create mode 100644 Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs create mode 100644 Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Interfaces/Legacy.meta create mode 100644 Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs create mode 100644 Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Legacy.meta create mode 100644 Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs create mode 100644 Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs.meta create mode 100644 Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs create mode 100644 Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs.meta delete mode 100644 Runtime/Purchasing/Analytics/UnityAnalytics.cs delete mode 100644 Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta delete mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs rename Runtime/Purchasing/Telemetry/Metrics/Interfaces/{ITelemetryMetrics.cs.meta => ITelemetryMetricsService.cs.meta} (100%) create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs.meta create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs rename Runtime/Purchasing/Telemetry/Metrics/{TelemetryMetricNames.cs.meta => TelemetryMetricDefinitions.cs.meta} (100%) delete mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs delete mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs create mode 100644 Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs rename Runtime/Purchasing/Telemetry/Metrics/{TelemetryMetrics.cs.meta => TelemetryMetricsService.cs.meta} (100%) create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs.meta create mode 100644 Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs create mode 100644 Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs.meta create mode 100644 Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs create mode 100644 Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs.meta create mode 100644 Runtime/Stores/BaseStore/MetricizedJsonStore.cs rename Runtime/{Purchasing/Analytics/EmptyUnityAnalytics.cs.meta => Stores/BaseStore/MetricizedJsonStore.cs.meta} (83%) create mode 100644 Samples~/06 InitializeGamingServices.meta create mode 100644 Samples~/06 InitializeGamingServices/InitializeGamingServices.cs create mode 100644 Samples~/06 InitializeGamingServices/InitializeGamingServices.cs.meta create mode 100644 Samples~/06 InitializeGamingServices/InitializeGamingServices.unity create mode 100644 Samples~/06 InitializeGamingServices/InitializeGamingServices.unity.meta create mode 100644 Samples~/06 InitializeGamingServices/README.md create mode 100644 Samples~/06 InitializeGamingServices/README.md.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 5481bab..b6f307b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog -## [4.2.0-pre.1] - 2022-04-13 +## [4.2.0-pre.2] - 2022-04-28 ### Added -- Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction). -- The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package. +- Support for Unity Analytics TransactionFailed event. +- Sample showcasing how to initialize [Unity Gaming Services](https://unity.com/solutions/gaming-services) using the [Services Core API](https://docs.unity.com/ugs-overview/services-core-api.html) + +### Changed +- The Analytics notice in the In-App Purchasing service window has been removed for Unity Editors 2022 and up. + +## [4.2.0-pre.1] - 2022-04-07 ### Changed - The minimum Unity Editor version supported is 2020.3. @@ -25,7 +30,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc ### Fixed - Removed deprecated UnityWebRequest calls, updating them to use safer ones. This avoids compiler warnings that may occur. -- Fixed a serious edge case where Apple StoreKit receipt parsing might fail, preventing validation. A portion of receipts on iOS could be affected and cause Unity IAP to freeze after the purchase completed, but before the SDK can finalize the purchase. The user will have to uninstall and reinstall your app in order to recover from this. Your customer service will have to refund the user's purchase or apply the purchase in some other way outside of Unity IAP. This bug was accidentally introduced in Unity IAP 4.1.0. To avoid encountering this problem with your app, we suggest you update to this version. +- Fixed edge case where Apple StoreKit receipt parsing would fail, preventing validation. ## [4.1.2] - 2021-11-15 diff --git a/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs b/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs index 6da2773..be7a5ce 100644 --- a/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs +++ b/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs @@ -10,7 +10,6 @@ static class UIResourceUtils internal static readonly string platformSupportUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/PlatformSupportVisual.uxml"; internal static readonly string googlePlayConfigUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/GooglePlayConfiguration.uxml"; internal static readonly string appleConfigUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/AppleConfiguration.uxml"; - internal static readonly string analyticsNoticeUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/AnalyticsNotice.uxml"; internal static readonly string platformSupportCommonUssPath = $"{SettingsUIConstants.packageUssRoot}/PlatformSupportVisualCommon.uss"; internal static readonly string platformSupportDarkUssPath = $"{SettingsUIConstants.packageUssRoot}/PlatformSupportVisualDark.uss"; diff --git a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs index d3f4f82..c2eb8d2 100644 --- a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs +++ b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs @@ -12,7 +12,6 @@ protected BasePurchasingState(string stateName, SimpleStateMachine stateMa : base(stateName, stateMachine) { m_UIBlocks = new List(); - m_UIBlocks.Add(CreateAnalyticsNoticeBlock()); m_UIBlocks.Add(PlatformsAndStoresServiceSettingsBlock.CreateStateSpecificBlock(IsEnabled())); } @@ -21,8 +20,6 @@ internal List GetStateUI() return m_UIBlocks.Select(block => block.GetUIBlockElement()).ToList(); } - protected abstract AnalyticsNoticeBlock CreateAnalyticsNoticeBlock(); - internal abstract bool IsEnabled(); } } diff --git a/Editor/ServiceProjectSettings/Presenter/PurchasingDisabledState.cs b/Editor/ServiceProjectSettings/Presenter/PurchasingDisabledState.cs index 4c6df2f..fea3bf3 100644 --- a/Editor/ServiceProjectSettings/Presenter/PurchasingDisabledState.cs +++ b/Editor/ServiceProjectSettings/Presenter/PurchasingDisabledState.cs @@ -15,11 +15,6 @@ SimpleStateMachine.State HandleEnabling(bool raisedEvent) return stateMachine.GetStateByName(PurchasingEnabledState.k_StateNameEnabled); } - protected override AnalyticsNoticeBlock CreateAnalyticsNoticeBlock() - { - return AnalyticsNoticeBlock.CreateDisabledAnalyticsBlock(); - } - internal override bool IsEnabled() => false; } } diff --git a/Editor/ServiceProjectSettings/Presenter/PurchasingEnabledState.cs b/Editor/ServiceProjectSettings/Presenter/PurchasingEnabledState.cs index ce89f30..390def1 100644 --- a/Editor/ServiceProjectSettings/Presenter/PurchasingEnabledState.cs +++ b/Editor/ServiceProjectSettings/Presenter/PurchasingEnabledState.cs @@ -19,11 +19,6 @@ SimpleStateMachine.State HandleDisabling(bool raisedEvent) return stateMachine.GetStateByName(PurchasingDisabledState.k_StateNameDisabled); } - protected override AnalyticsNoticeBlock CreateAnalyticsNoticeBlock() - { - return AnalyticsNoticeBlock.CreateEnabledAnalyticsBlock(); - } - internal override bool IsEnabled() => true; } } diff --git a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml deleted file mode 100644 index 8ecc71e..0000000 --- a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - diff --git a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml.meta b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml.meta deleted file mode 100644 index 3407628..0000000 --- a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsNotice.uxml.meta +++ /dev/null @@ -1,10 +0,0 @@ -fileFormatVersion: 2 -guid: d660ad34b13f1b24fb2ccb859c8c5396 -ScriptedImporter: - internalIDToNameTable: [] - externalObjects: {} - serializedVersion: 2 - userData: - assetBundleName: - assetBundleVariant: - script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0} diff --git a/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs b/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs deleted file mode 100644 index 12086ba..0000000 --- a/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs +++ /dev/null @@ -1,67 +0,0 @@ -using UnityEngine.UIElements; - -namespace UnityEditor.Purchasing -{ - internal class AnalyticsNoticeBlock : IPurchasingSettingsUIBlock - { - const string k_EnabledNoticeSectionName = "EnabledNoticeSection"; - const string k_DisabledNoticeSectionName = "DisabledNoticeSection"; - - private string m_ActiveSectionName; - - VisualElement m_NoticeBlock; - - internal static AnalyticsNoticeBlock CreateEnabledAnalyticsBlock() - { - return new AnalyticsNoticeBlock(k_EnabledNoticeSectionName); - } - - internal static AnalyticsNoticeBlock CreateDisabledAnalyticsBlock() - { - return new AnalyticsNoticeBlock(k_DisabledNoticeSectionName); - } - - private AnalyticsNoticeBlock(string activeSection) - { - m_ActiveSectionName = activeSection; - } - - public VisualElement GetUIBlockElement() - { - return SetupConfigBlock(); - } - - VisualElement SetupConfigBlock() - { - m_NoticeBlock = SettingsUIUtils.CloneUIFromTemplate(UIResourceUtils.analyticsNoticeUxmlPath); - - SetupNoticeBlock(); - SetupStyleSheets(); - - return m_NoticeBlock; - } - - void SetupNoticeBlock() - { - ToggleStateSectionVisibility(k_EnabledNoticeSectionName); - ToggleStateSectionVisibility(k_DisabledNoticeSectionName); - } - - void ToggleStateSectionVisibility(string sectionName) - { - var errorSection = m_NoticeBlock.Q(sectionName); - if (errorSection != null) - { - errorSection.style.display = (sectionName == m_ActiveSectionName) - ? DisplayStyle.Flex - : DisplayStyle.None; - } - } - - void SetupStyleSheets() - { - m_NoticeBlock.AddStyleSheetPath(UIResourceUtils.purchasingCommonUssPath); - m_NoticeBlock.AddStyleSheetPath(EditorGUIUtility.isProSkin ? UIResourceUtils.purchasingDarkUssPath : UIResourceUtils.purchasingLightUssPath); - } - } -} diff --git a/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs.meta b/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs.meta deleted file mode 100644 index 6653862..0000000 --- a/Editor/ServiceProjectSettings/UI/Views/AnalyticsNoticeBlock.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5b9719e8f9fa4fc4dbecf678a6192c5d -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing b/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing index b68265fcb7d20708956d42ef160172f8f9c16896..32acd0504f24576e398a8314063d654f3e713094 100644 GIT binary patch delta 187 zcmbQxCp4i?s9_6ZLlX1XXIa~ulNfs$nZE&9!YPcO)`;wsIJY#YTgRzkcHzY<4izH5 z<7#GamlR|=$*jN|wmn>ZSNCJrSHC8iK3yVs@KS|guEG4K)82a5f6R9}+|DG*w4F(m zS?&PXWaX>Ou@IA+uQBtbiafb*SCP}WbGLEsx`it(e(#E#zNc>cQfro*%nIyRwSTY6 m%TBbEP!llM*H7qswlp}C?`33%D`|rqT;#J zcW##yWID;Luxmoe;lqMgQ#UT+Z`2DHH{j=NdCndBa!JUx6Z+w~?^)ZKM47fTi89L_ z0Gp?Ll{pq-a`QE2zEqK|_JT=jMo-;DZ!-U@JXQYey1BT-_NCS=H<=X*6Q!=V71+GW j&ur!qp2@NK=l1JIC$Co$e)~f*ZP_MQ;qCM6S&G~NR)J0J diff --git a/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs new file mode 100644 index 0000000..e6fd36c --- /dev/null +++ b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using Unity.Services.Analytics; + +namespace UnityEngine.Purchasing +{ + class AnalyticsAdapter : IAnalyticsAdapter + { + IAnalyticsService m_Analytics; + + public AnalyticsAdapter(IAnalyticsService analytics) + { + m_Analytics = analytics; + } + + public void SendTransactionEvent(Product product) + { + var unifiedReceipt = JsonUtility.FromJson(product.receipt); + var analyticsReceipt = unifiedReceipt.ToReceiptAndSignature(); + var txParams = BuildTransactionParameters(product, unifiedReceipt, analyticsReceipt); + m_Analytics.Transaction(txParams); + } + + TransactionParameters BuildTransactionParameters(Product product, + UnifiedReceipt unifiedReceipt, AnalyticsTransactionReceipt analyticsReceipt) + { + return new TransactionParameters + { + ProductID = product.definition.storeSpecificId, + TransactionName = product.metadata.localizedTitle, + TransactionID = unifiedReceipt.TransactionID, + TransactionType = TransactionType.PURCHASE, + TransactionReceipt = analyticsReceipt.transactionReceipt, + TransactionReceiptSignature = analyticsReceipt.transactionReceiptSignature, + TransactionServer = analyticsReceipt.transactionServer, + ProductsReceived = GenerateItemReceivedForPurchase(product), + ProductsSpent = GenerateRealCurrencySpentOnPurchase(product) + }; + } + + public void SendTransactionFailedEvent(Product product, PurchaseFailureReason reason) + { + var transactionFailedParameters = BuildTransactionFailedParameters(product, reason); + m_Analytics.TransactionFailed(transactionFailedParameters); + } + + TransactionFailedParameters BuildTransactionFailedParameters(Product product, + PurchaseFailureReason reason) + { + return new TransactionFailedParameters + { + ProductID = product.definition.storeSpecificId, + TransactionName = product.metadata.localizedTitle, + TransactionType = TransactionType.PURCHASE, + ProductsReceived = GenerateItemReceivedForPurchase(product), + ProductsSpent = GenerateRealCurrencySpentOnPurchase(product), + FailureReason = reason.ToString() + }; + } + + Unity.Services.Analytics.Product GenerateItemReceivedForPurchase(Product product) + { + return new Unity.Services.Analytics.Product + { + Items = new List + { + new Item + { + ItemName = product.definition.id, + ItemType = product.definition.type.ToString(), + ItemAmount = 1 + } + } + }; + } + + Unity.Services.Analytics.Product GenerateRealCurrencySpentOnPurchase(Product product) + { + return new Unity.Services.Analytics.Product + { + RealCurrency = new RealCurrency + { + RealCurrencyType = product.metadata.isoCurrencyCode, + RealCurrencyAmount = ExtractRealCurrencyAmount(product) + } + }; + } + + long ExtractRealCurrencyAmount(Product product) + { + return m_Analytics.ConvertCurrencyToMinorUnits(product.metadata.isoCurrencyCode, + (double) product.metadata.localizedPrice); + } + } +} diff --git a/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs.meta b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs.meta new file mode 100644 index 0000000..95ca087 --- /dev/null +++ b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f7a41dfe17864d419c3bb474d74dbf07 +timeCreated: 1649722777 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/AnalyticsClient.cs b/Runtime/Purchasing/Analytics/AnalyticsClient.cs new file mode 100644 index 0000000..d352eca --- /dev/null +++ b/Runtime/Purchasing/Analytics/AnalyticsClient.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace UnityEngine.Purchasing +{ + class AnalyticsClient : IAnalyticsClient + { + IAnalyticsAdapter m_Analytics; + IAnalyticsAdapter m_LegacyAnalytics; + + public AnalyticsClient(IAnalyticsAdapter analytics, IAnalyticsAdapter legacyAnalytics) + { + m_Analytics = analytics; + m_LegacyAnalytics = legacyAnalytics; + } + + public void OnPurchaseSucceeded(Product product) + { + if (product.metadata.isoCurrencyCode == null) + { + return; + } + + m_Analytics.SendTransactionEvent(product); + m_LegacyAnalytics.SendTransactionEvent(product); + } + + public void OnPurchaseFailed(Product product, PurchaseFailureReason reason) + { + m_Analytics.SendTransactionFailedEvent(product, reason); + m_LegacyAnalytics.SendTransactionFailedEvent(product, reason); + } + } +} diff --git a/Runtime/Purchasing/Analytics/AnalyticsReporter.cs.meta b/Runtime/Purchasing/Analytics/AnalyticsClient.cs.meta similarity index 100% rename from Runtime/Purchasing/Analytics/AnalyticsReporter.cs.meta rename to Runtime/Purchasing/Analytics/AnalyticsClient.cs.meta diff --git a/Runtime/Purchasing/Analytics/AnalyticsReporter.cs b/Runtime/Purchasing/Analytics/AnalyticsReporter.cs deleted file mode 100644 index 7006bad..0000000 --- a/Runtime/Purchasing/Analytics/AnalyticsReporter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; - -namespace UnityEngine.Purchasing -{ - /// - /// Relays IAP Transaction information to Unity Analytics. - /// - /// Responsible for adapting Unity Purchasing's unified - /// receipts for Unity Analytics' Transaction API. - /// - class AnalyticsReporter - { - IUnityAnalytics m_Analytics; - - public AnalyticsReporter(IUnityAnalytics analytics) - { - m_Analytics = analytics; - } - - public void OnPurchaseSucceeded(Product product) - { - if (null == product.metadata.isoCurrencyCode) - { - return; - } - - m_Analytics.SendTransactionEvent(product); - } - - public void OnPurchaseFailed(Product product, PurchaseFailureReason reason) - { - var data = new Dictionary() { - { "productID", product.definition.storeSpecificId }, - { "reason", reason }, - { "price", product.metadata.localizedPrice }, - { "currency", product.metadata.isoCurrencyCode } - }; - m_Analytics.SendCustomEvent("unity.PurchaseFailed", data); - } - } -} diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs b/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs deleted file mode 100644 index 0b7f59e..0000000 --- a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; - -namespace UnityEngine.Purchasing -{ - /// - /// Forward transaction information to Unity Analytics. - /// - class EmptyUnityAnalytics : IUnityAnalytics - { - public void SendTransactionEvent(Product product) - { - } - - public void SendCustomEvent(string name, Dictionary data) - { - } - } -} diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs b/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs new file mode 100644 index 0000000..0d3ee26 --- /dev/null +++ b/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs @@ -0,0 +1,15 @@ +using Unity.Services.Analytics; + +namespace UnityEngine.Purchasing +{ + class EmptyAnalyticsAdapter : IAnalyticsAdapter + { + public void SendTransactionEvent(Product product) + { + } + + public void SendTransactionFailedEvent(Product product, PurchaseFailureReason reason) + { + } + } +} diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs.meta b/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs.meta new file mode 100644 index 0000000..7010ec4 --- /dev/null +++ b/Runtime/Purchasing/Analytics/EmptyUnityAnalyticsAdapter.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 771dd3cad10143af9ef50871cb12359e +timeCreated: 1649721254 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/IUnityAnalytics.cs b/Runtime/Purchasing/Analytics/IUnityAnalytics.cs deleted file mode 100644 index b3d7208..0000000 --- a/Runtime/Purchasing/Analytics/IUnityAnalytics.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; - -namespace UnityEngine.Purchasing -{ - /// - /// Extracted from Unity Analytics for testability. - /// - interface IUnityAnalytics - { - void SendTransactionEvent(Product product); - void SendCustomEvent(string name, Dictionary data); - } -} diff --git a/Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta deleted file mode 100644 index 635c51b..0000000 --- a/Runtime/Purchasing/Analytics/IUnityAnalytics.cs.meta +++ /dev/null @@ -1,10 +0,0 @@ -fileFormatVersion: 2 -guid: 23a3ebfba7864c4fb2fcf3a8503856d3 -folderAsset: yes -timeCreated: 1556736 -licenseType: Pro -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/Purchasing/Analytics/Interfaces.meta b/Runtime/Purchasing/Analytics/Interfaces.meta new file mode 100644 index 0000000..750d57a --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 421f0f56ec1e4dc5a8cb03f0d55e19cf +timeCreated: 1649723964 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs new file mode 100644 index 0000000..2f65c19 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs @@ -0,0 +1,8 @@ +namespace UnityEngine.Purchasing +{ + interface IAnalyticsAdapter + { + void SendTransactionEvent(Product product); + void SendTransactionFailedEvent(Product product, PurchaseFailureReason reason); + } +} diff --git a/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs.meta b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs.meta new file mode 100644 index 0000000..6c00ad2 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsAdapter.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8822bcc6c9f94378acf292c0f5e8bc99 +timeCreated: 1649722285 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs new file mode 100644 index 0000000..2b20303 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs @@ -0,0 +1,8 @@ +namespace UnityEngine.Purchasing +{ + internal interface IAnalyticsClient + { + void OnPurchaseSucceeded(Product product); + void OnPurchaseFailed(Product product, PurchaseFailureReason reason); + } +} diff --git a/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs.meta b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs.meta new file mode 100644 index 0000000..1d96ebe --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/IAnalyticsClient.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 24124273374e421284456346db99f343 +timeCreated: 1649723935 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Interfaces/Legacy.meta b/Runtime/Purchasing/Analytics/Interfaces/Legacy.meta new file mode 100644 index 0000000..737a60e --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/Legacy.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3b4c8676988f43f1b9fe1b5c391cff46 +timeCreated: 1649723979 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs b/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs new file mode 100644 index 0000000..65319e4 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace UnityEngine.Purchasing +{ + interface ILegacyUnityAnalytics + { + void SendTransactionEvent(string productId, Decimal amount, string currency, string receiptPurchaseData, + string signature); + + void SendCustomEvent(string name, Dictionary data); + } +} diff --git a/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs.meta new file mode 100644 index 0000000..486b971 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Interfaces/Legacy/ILegacyUnityAnalytics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6a8a9e9e477940b9920dcf44492d2d6b +timeCreated: 1649718603 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Legacy.meta b/Runtime/Purchasing/Analytics/Legacy.meta new file mode 100644 index 0000000..6903230 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Legacy.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 467209943d36403b8ed15c3412dbbe78 +timeCreated: 1649721661 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs b/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs new file mode 100644 index 0000000..5bd1cd4 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace UnityEngine.Purchasing +{ + class LegacyAnalyticsAdapter : IAnalyticsAdapter + { + ILegacyUnityAnalytics m_LegacyAnalytics; + + public LegacyAnalyticsAdapter(ILegacyUnityAnalytics legacyAnalytics) + { + m_LegacyAnalytics = legacyAnalytics; + } + + public void SendTransactionEvent(Product product) + { + m_LegacyAnalytics.SendTransactionEvent(product.definition.storeSpecificId, + product.metadata.localizedPrice, + product.metadata.isoCurrencyCode, + product.receipt, + null); + } + + public void SendTransactionFailedEvent(Product product, PurchaseFailureReason reason) + { + var data = new Dictionary() + { + {"productID", product.definition.storeSpecificId}, + {"reason", reason}, + {"price", product.metadata.localizedPrice}, + {"currency", product.metadata.isoCurrencyCode} + }; + m_LegacyAnalytics.SendCustomEvent("unity.PurchaseFailed", data); + } + } +} diff --git a/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs.meta b/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs.meta new file mode 100644 index 0000000..ba6ce8e --- /dev/null +++ b/Runtime/Purchasing/Analytics/Legacy/LegacyAnalyticsAdapter.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fc0937abe468495aaa8fa4bb6552711a +timeCreated: 1649722272 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs new file mode 100644 index 0000000..d2d27f7 --- /dev/null +++ b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using Unity.Services.Analytics; + +namespace UnityEngine.Purchasing +{ + class LegacyUnityAnalytics : ILegacyUnityAnalytics + { + public void SendTransactionEvent(string productId, Decimal amount, string currency, string receiptPurchaseData, + string signature) + { + Analytics.Analytics.Transaction(productId, amount, currency, receiptPurchaseData, signature); + } + + public void SendCustomEvent(string name, Dictionary data) + { + Analytics.Analytics.CustomEvent(name, data); + } + } +} diff --git a/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs.meta new file mode 100644 index 0000000..49bff6c --- /dev/null +++ b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a9e37527ab3440f2a94ba18a3ee4b2aa +timeCreated: 1649718781 \ No newline at end of file diff --git a/Runtime/Purchasing/Analytics/UnityAnalytics.cs b/Runtime/Purchasing/Analytics/UnityAnalytics.cs deleted file mode 100644 index bb6c7c4..0000000 --- a/Runtime/Purchasing/Analytics/UnityAnalytics.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using Unity.Services.Analytics; -using UnityEngine; - -namespace UnityEngine.Purchasing -{ - /// - /// Forward transaction information to Unity Analytics. - /// - class UnityAnalytics : IUnityAnalytics - { - public void SendTransactionEvent(Product product) - { -#if ENABLE_CLOUD_SERVICES_ANALYTICS - Analytics.Analytics.Transaction(product.definition.storeSpecificId, - product.metadata.localizedPrice, - product.metadata.isoCurrencyCode, - product.receipt, - null); -#endif - var unifiedReceipt = JsonUtility.FromJson(product.receipt); - var analyticsReceipt = unifiedReceipt.ToReceiptAndSignature(); - var txParams = BuildTransactionParameters(product, unifiedReceipt, analyticsReceipt); - - AnalyticsService.Instance.Transaction(txParams); - } - - public void SendCustomEvent(string name, Dictionary data) - { -#if ENABLE_CLOUD_SERVICES_ANALYTICS - Analytics.Analytics.CustomEvent(name, data); -#endif - } - - static TransactionParameters BuildTransactionParameters(Product product, UnifiedReceipt unifiedReceipt, AnalyticsTransactionReceipt analyticsReceipt) - { - var txParams = new TransactionParameters - { - ProductID = product.definition.storeSpecificId, - TransactionName = product.metadata.localizedTitle, - TransactionID = unifiedReceipt.TransactionID, - TransactionType = TransactionType.PURCHASE, - TransactionReceipt = analyticsReceipt.transactionReceipt, - TransactionReceiptSignature = analyticsReceipt.transactionReceiptSignature, - TransactionServer = analyticsReceipt.transactionServer, - ProductsReceived = new Unity.Services.Analytics.Product - { - Items = new List - { - new Item - { - ItemName = product.definition.id, - ItemType = product.definition.type.ToString(), - ItemAmount = 1 - } - } - }, - ProductsSpent = new Unity.Services.Analytics.Product - { - RealCurrency = new RealCurrency - { - RealCurrencyType = product.metadata.isoCurrencyCode, - RealCurrencyAmount = AnalyticsService.Instance.ConvertCurrencyToMinorUnits(product.metadata.isoCurrencyCode, (double)product.metadata.localizedPrice) - } - } - }; - return txParams; - } - } -} diff --git a/Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta b/Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta deleted file mode 100644 index 505e059..0000000 --- a/Runtime/Purchasing/Analytics/UnityAnalytics.cs.meta +++ /dev/null @@ -1,10 +0,0 @@ -fileFormatVersion: 2 -guid: a7e2359fa40746b58e2d2b039e5a895c -folderAsset: yes -timeCreated: 1388428 -licenseType: Pro -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/Purchasing/StoreListenerProxy.cs b/Runtime/Purchasing/StoreListenerProxy.cs index d4d376a..cbad3b9 100644 --- a/Runtime/Purchasing/StoreListenerProxy.cs +++ b/Runtime/Purchasing/StoreListenerProxy.cs @@ -7,11 +7,11 @@ namespace UnityEngine.Purchasing /// internal class StoreListenerProxy : IInternalStoreListener { - private AnalyticsReporter m_Analytics; + private IAnalyticsClient m_Analytics; private IStoreListener m_ForwardTo; private IExtensionProvider m_Extensions; - public StoreListenerProxy(IStoreListener forwardTo, AnalyticsReporter analytics, IExtensionProvider extensions) + public StoreListenerProxy(IStoreListener forwardTo, IAnalyticsClient analytics, IExtensionProvider extensions) { m_ForwardTo = forwardTo; m_Analytics = analytics; diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs deleted file mode 100644 index 62cc675..0000000 --- a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace UnityEngine.Purchasing.Telemetry -{ - interface ITelemetryMetrics - { - ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricTypes metricType, string metricName); - } -} diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs new file mode 100644 index 0000000..47f288a --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs @@ -0,0 +1,10 @@ +using System; + +namespace UnityEngine.Purchasing.Telemetry +{ + interface ITelemetryMetricsService + { + void ExecuteTimedAction(Action timedAction, TelemetryMetricDefinition metricDefinition); + ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricDefinition metricDefinition); + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs.meta similarity index 100% rename from Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetrics.cs.meta rename to Runtime/Purchasing/Telemetry/Metrics/Interfaces/ITelemetryMetricsService.cs.meta diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs new file mode 100644 index 0000000..27d79b3 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs @@ -0,0 +1,17 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + struct TelemetryMetricDefinition + { + public TelemetryMetricTypes MetricType { get; } + public string MetricName { get; } + + public TelemetryMetricDefinition(string metricName, + TelemetryMetricTypes metricType = TelemetryMetricTypes.Histogram) + { + MetricName = metricName; + MetricType = metricType; + } + + public static implicit operator TelemetryMetricDefinition(string name) => new TelemetryMetricDefinition(name); + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs.meta new file mode 100644 index 0000000..a8b925f --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinition.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 17dda52e19ce4bdca57c1479622727c3 +timeCreated: 1649968886 \ No newline at end of file diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs new file mode 100644 index 0000000..f29f1e5 --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs @@ -0,0 +1,22 @@ +namespace UnityEngine.Purchasing.Telemetry +{ + static class TelemetryMetricDefinitions + { + internal static readonly TelemetryMetricDefinition + confirmSubscriptionPriceChangeName = "confirm_subscription_price_change", + continuePromotionalPurchasesName = "continue_promotional_purchases", + dequeueQueryProductsTimeName = "dequeue_query_products_time", + dequeueQueryPurchasesTimeName = "dequeue_query_purchases_time", + fetchStorePromotionOrderName = "fetch_store_promotion_order", + fetchStorePromotionVisibilityName = "fetch_store_promotion_visibility", + initPurchaseName = "init_purchase", + packageInitTimeName = "package_init_time", + presentCodeRedemptionSheetName = "present_code_redemption_sheet", + refreshAppReceiptName = "refresh_app_receipt", + restoreTransactionName = "restore_transaction", + retrieveProductsName = "retrieve_products", + setStorePromotionOrderName = "set_store_promotion_order", + setStorePromotionVisibilityName = "set_store_promotion_visibility", + upgradeDowngradeSubscriptionName = "upgrade_downgrade_subscription"; + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs.meta similarity index 100% rename from Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs.meta rename to Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricDefinitions.cs.meta diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs deleted file mode 100644 index dabccba..0000000 --- a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricNames.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace UnityEngine.Purchasing.Telemetry -{ - static class TelemetryMetricNames - { - internal const string confirmSubscriptionPriceChangeName = "confirm_subscription_price_change"; - internal const string continuePromotionalPurchasesName = "continue_promotional_purchases"; - internal const string dequeueQueryProductsTimeName = "dequeue_query_products_time"; - internal const string dequeueQueryPurchasesTimeName = "dequeue_query_purchases_time"; - internal const string fetchStorePromotionOrderName = "fetch_store_promotion_order"; - internal const string fetchStorePromotionVisibilityName = "fetch_store_promotion_visibility"; - internal const string initPurchaseName = "init_purchase"; - internal const string packageInitTimeName = "package_init_time"; - internal const string presentCodeRedemptionSheetName = "present_code_redemption_sheet"; - internal const string refreshAppReceiptName = "refresh_app_receipt"; - internal const string restoreTransactionName = "restore_transaction"; - internal const string retrieveProductsName = "retrieve_products"; - internal const string setStorePromotionOrderName = "set_store_promotion_order"; - internal const string setStorePromotionVisibilityName = "set_store_promotion_visibility"; - internal const string upgradeDowngradeSubscriptionName = "upgrade_downgrade_subscription"; - } -} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs deleted file mode 100644 index 3dbbe5d..0000000 --- a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; - -namespace UnityEngine.Purchasing.Telemetry -{ - class TelemetryMetrics : ITelemetryMetrics - { - ITelemetryMetricsInstanceWrapper m_TelemetryMetricsInstanceWrapper; - - public TelemetryMetrics(ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper) - { - m_TelemetryMetricsInstanceWrapper = telemetryMetricsInstanceWrapper; - } - - public ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricTypes metricType, string metricName) - { - ITelemetryMetricEvent metricEvent = new TelemetryMetricEvent(m_TelemetryMetricsInstanceWrapper, metricType, metricName); - metricEvent.StartMetric(); - return metricEvent; - } - } -} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs new file mode 100644 index 0000000..3e79c4d --- /dev/null +++ b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace UnityEngine.Purchasing.Telemetry +{ + class TelemetryMetricsService : ITelemetryMetricsService + { + ITelemetryMetricsInstanceWrapper m_TelemetryMetricsInstanceWrapper; + + public TelemetryMetricsService(ITelemetryMetricsInstanceWrapper telemetryMetricsInstanceWrapper) + { + m_TelemetryMetricsInstanceWrapper = telemetryMetricsInstanceWrapper; + } + + public void ExecuteTimedAction(Action timedAction, TelemetryMetricDefinition metricDefinition) + { + var handle = CreateAndStartMetricEvent(metricDefinition); + timedAction(); + handle.StopAndSendMetric(); + } + + public ITelemetryMetricEvent CreateAndStartMetricEvent(TelemetryMetricDefinition metricDefinition) + { + ITelemetryMetricEvent metricEvent = new TelemetryMetricEvent(m_TelemetryMetricsInstanceWrapper, metricDefinition.MetricType, metricDefinition.MetricName); + metricEvent.StartMetric(); + return metricEvent; + } + } +} diff --git a/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta b/Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs.meta similarity index 100% rename from Runtime/Purchasing/Telemetry/Metrics/TelemetryMetrics.cs.meta rename to Runtime/Purchasing/Telemetry/Metrics/TelemetryMetricsService.cs.meta diff --git a/Runtime/Purchasing/UnityPurchasing.cs b/Runtime/Purchasing/UnityPurchasing.cs index 9059d3b..262560e 100644 --- a/Runtime/Purchasing/UnityPurchasing.cs +++ b/Runtime/Purchasing/UnityPurchasing.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Unity.Services.Analytics; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing @@ -16,10 +17,25 @@ public abstract class UnityPurchasing /// The ConfigurationBuilder containing the product definitions mapped to stores public static void Initialize(IStoreListener listener, ConfigurationBuilder builder) { + Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, + GenerateUnityAnalytics(), GenerateLegacyUnityAnalytics(), builder.factory.GetCatalogProvider()); + } + + private static IAnalyticsAdapter GenerateUnityAnalytics() + { #if DISABLE_RUNTIME_IAP_ANALYTICS - Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, new EmptyUnityAnalytics(), builder.factory.GetCatalogProvider()); + return new EmptyAnalyticsAdapter(); +#else + return new AnalyticsAdapter(AnalyticsService.Instance); +#endif + } + + private static IAnalyticsAdapter GenerateLegacyUnityAnalytics() + { +#if DISABLE_RUNTIME_IAP_ANALYTICS || !ENABLE_CLOUD_SERVICES_ANALYTICS + return new EmptyAnalyticsAdapter(); #else - Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, new UnityAnalytics(), builder.factory.GetCatalogProvider()); + return new LegacyAnalyticsAdapter(new LegacyUnityAnalytics()); #endif } @@ -40,14 +56,15 @@ public static void ClearTransactionLog() /// Created for integration testing. /// internal static void Initialize(IStoreListener listener, ConfigurationBuilder builder, - ILogger logger, string persistentDatapath, IUnityAnalytics analytics, ICatalogProvider catalog) + ILogger logger, string persistentDatapath, IAnalyticsAdapter analytics, IAnalyticsAdapter legacyAnalytics, + ICatalogProvider catalog) { var transactionLog = new TransactionLog(logger, persistentDatapath); var manager = new PurchasingManager(transactionLog, logger, builder.factory.service, builder.factory.storeName); - var analyticsReporter = new AnalyticsReporter(analytics); + var analyticsClient = new AnalyticsClient(analytics, legacyAnalytics); // Proxy the PurchasingManager's callback interface to forward Transactions to Analytics. - var proxy = new StoreListenerProxy(listener, analyticsReporter, builder.factory); + var proxy = new StoreListenerProxy(listener, analyticsClient, builder.factory); FetchAndMergeProducts(builder.useCatalogProvider, builder.products, catalog, response => { manager.Initialize(proxy, response); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs index 09e4c2d..827f3eb 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs @@ -25,7 +25,6 @@ class GooglePlayStoreService : IGooglePlayStoreService IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; IGooglePriceChangeService m_GooglePriceChangeService; IGoogleLastKnownProductService m_GoogleLastKnownProductService; - ITelemetryMetrics m_TelemetryMetrics; internal GooglePlayStoreService( IGoogleBillingClient billingClient, @@ -35,8 +34,7 @@ internal GooglePlayStoreService( IGoogleQueryPurchasesService queryPurchasesService, IBillingClientStateListener billingClientStateListener, IGooglePriceChangeService priceChangeService, - IGoogleLastKnownProductService lastKnownProductService, - ITelemetryMetrics telemetryMetrics) + IGoogleLastKnownProductService lastKnownProductService) { m_BillingClient = billingClient; m_QuerySkuDetailsService = querySkuDetailsService; @@ -46,7 +44,6 @@ internal GooglePlayStoreService( m_GooglePriceChangeService = priceChangeService; m_GoogleLastKnownProductService = lastKnownProductService; m_BillingClientStateListener = billingClientStateListener; - m_TelemetryMetrics = telemetryMetrics; InitConnectionWithGooglePlay(); } @@ -88,9 +85,8 @@ void OnConnected() DequeueFetchPurchases(); } - void DequeueQueryProducts() + protected virtual void DequeueQueryProducts() { - var dequeueQueryProductsMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.dequeueQueryProductsTimeName); var productsFailedToDequeue = new Queue(); var stop = false; @@ -132,18 +128,15 @@ void DequeueQueryProducts() { m_ProductsToQuery.Enqueue(product); } - dequeueQueryProductsMetric.StopAndSendMetric(); } - void DequeueFetchPurchases() + protected virtual void DequeueFetchPurchases() { - var dequeueQueryPurchasesMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.dequeueQueryPurchasesTimeName); while (m_OnPurchaseSucceededQueue.Count > 0) { var onPurchaseSucceed = m_OnPurchaseSucceededQueue.Dequeue(); FetchPurchases(onPurchaseSucceed); } - dequeueQueryPurchasesMetric.StopAndSendMetric(); } void OnDisconnected() @@ -176,9 +169,8 @@ void OnReconnectionFailure() DequeueQueryProducts(); } - public void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) + public virtual void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) { - var retrieveProductsMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.retrieveProductsName); if (m_GoogleConnectionState == GoogleBillingConnectionState.Connected) { m_QuerySkuDetailsService.QueryAsyncSku(products, onProductsReceived); @@ -187,7 +179,6 @@ public void RetrieveProducts(ReadOnlyCollection products, Act { HandleRetrieveProductsNotConnected(products, onProductsReceived, onRetrieveProductsFailed); } - retrieveProductsMetric.StopAndSendMetric(); } void HandleRetrieveProductsNotConnected(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) @@ -206,13 +197,11 @@ public void Purchase(ProductDefinition product) Purchase(product, null, null); } - public void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) + public virtual void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) { - var initPurchaseMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.initPurchaseName); m_GoogleLastKnownProductService.SetLastKnownProductId(product.storeSpecificId); m_GoogleLastKnownProductService.SetLastKnownProrationMode(desiredProrationMode); m_GooglePurchaseService.Purchase(product, oldProduct, desiredProrationMode); - initPurchaseMetric.StopAndSendMetric(); } public void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge) @@ -242,11 +231,9 @@ public void SetObfuscatedProfileId(string obfuscatedProfileId) m_BillingClient.SetObfuscationProfileId(obfuscatedProfileId); } - public void ConfirmSubscriptionPriceChange(ProductDefinition product, Action onPriceChangeAction) + public virtual void ConfirmSubscriptionPriceChange(ProductDefinition product, Action onPriceChangeAction) { - var confirmSubscriptionPriceChangeMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.confirmSubscriptionPriceChangeName); m_GooglePriceChangeService.PriceChange(product, onPriceChangeAction); - confirmSubscriptionPriceChangeMetric.StopAndSendMetric(); } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs new file mode 100644 index 0000000..ff24b73 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using UnityEngine.Purchasing.Extension; +using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Telemetry; + +namespace UnityEngine.Purchasing +{ + class MetricizedGooglePlayStoreService : GooglePlayStoreService + { + ITelemetryMetricsService m_TelemetryMetricsService; + + internal MetricizedGooglePlayStoreService( + IGoogleBillingClient billingClient, + IQuerySkuDetailsService querySkuDetailsService, + IGooglePurchaseService purchaseService, + IGoogleFinishTransactionService finishTransactionService, + IGoogleQueryPurchasesService queryPurchasesService, + IBillingClientStateListener billingClientStateListener, + IGooglePriceChangeService priceChangeService, + IGoogleLastKnownProductService lastKnownProductService, + ITelemetryMetricsService telemetryMetricsService) + : base(billingClient, querySkuDetailsService, purchaseService, finishTransactionService, + queryPurchasesService, billingClientStateListener, priceChangeService, lastKnownProductService) + { + m_TelemetryMetricsService = telemetryMetricsService; + } + + protected override void DequeueQueryProducts() + { + m_TelemetryMetricsService.ExecuteTimedAction( + base.DequeueQueryProducts, + TelemetryMetricDefinitions.dequeueQueryProductsTimeName); + } + + protected override void DequeueFetchPurchases() + { + m_TelemetryMetricsService.ExecuteTimedAction( + base.DequeueFetchPurchases, + TelemetryMetricDefinitions.dequeueQueryPurchasesTimeName); + } + + public override void RetrieveProducts(ReadOnlyCollection products, + Action> onProductsReceived, + Action onRetrieveProductsFailed) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RetrieveProducts(products, onProductsReceived, onRetrieveProductsFailed), + TelemetryMetricDefinitions.retrieveProductsName); + } + + public override void Purchase(ProductDefinition product, Product oldProduct, + GooglePlayProrationMode? desiredProrationMode) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.Purchase(product, oldProduct, desiredProrationMode), + TelemetryMetricDefinitions.initPurchaseName); + } + + public override void ConfirmSubscriptionPriceChange(ProductDefinition product, + Action onPriceChangeAction) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.ConfirmSubscriptionPriceChange(product, onPriceChangeAction), + TelemetryMetricDefinitions.confirmSubscriptionPriceChangeName); + } + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs.meta new file mode 100644 index 0000000..e79a46a --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 36fedfea4bfd44438fbc0364f8016ee9 +timeCreated: 1649944722 \ No newline at end of file diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs index ac1eb50..0bbd3a9 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs @@ -12,17 +12,15 @@ class GooglePlayStoreExtensions: IGooglePlayStoreExtensions, IGooglePlayStoreExt IGooglePlayStoreService m_GooglePlayStoreService; IGooglePlayStoreFinishTransactionService m_GooglePlayStoreFinishTransactionService; ITelemetryDiagnostics m_TelemetryDiagnostics; - ITelemetryMetrics m_TelemetryMetrics; IStoreCallback m_StoreCallback; Action m_DeferredPurchaseAction; Action m_DeferredProrationUpgradeDowngradeSubscriptionAction; - internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) + internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics) { m_GooglePlayStoreService = googlePlayStoreService; m_GooglePlayStoreFinishTransactionService = googlePlayStoreFinishTransactionService; m_TelemetryDiagnostics = telemetryDiagnostics; - m_TelemetryMetrics = telemetryMetrics; } public void UpgradeDowngradeSubscription(string oldSku, string newSku) @@ -35,9 +33,8 @@ public void UpgradeDowngradeSubscription(string oldSku, string newSku, int desir UpgradeDowngradeSubscription(oldSku, newSku, (GooglePlayProrationMode) desiredProrationMode); } - public void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePlayProrationMode desiredProrationMode) + public virtual void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePlayProrationMode desiredProrationMode) { - var upgradeDowngradeSubscriptionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.upgradeDowngradeSubscriptionName); Product product = m_StoreCallback.FindProductById(newSku); Product oldProduct = m_StoreCallback.FindProductById(oldSku); if (product != null && product.definition.type == ProductType.Subscription && @@ -53,12 +50,10 @@ public void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePla PurchaseFailureReason.ProductUnavailable, "Please verify that the products are subscriptions and are not null.")); } - upgradeDowngradeSubscriptionMetric.StopAndSendMetric(); } - public void RestoreTransactions(Action callback) + public virtual void RestoreTransactions(Action callback) { - var restoreTransactionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.restoreTransactionName); m_GooglePlayStoreService.FetchPurchases(purchase => { if (purchase != null) @@ -66,7 +61,6 @@ public void RestoreTransactions(Action callback) callback(true); } }); - restoreTransactionMetric.StopAndSendMetric(); } public void FinishAdditionalTransaction(string productId, string transactionId) diff --git a/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs new file mode 100644 index 0000000..b23b2f9 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs @@ -0,0 +1,34 @@ +using System; +using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Telemetry; + +namespace UnityEngine.Purchasing +{ + class MetricizedGooglePlayStoreExtensions : GooglePlayStoreExtensions + { + ITelemetryMetricsService m_TelemetryMetricsService; + + + internal MetricizedGooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, + IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, + ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetricsService telemetryMetricsService) + : base(googlePlayStoreService, googlePlayStoreFinishTransactionService, telemetryDiagnostics) + { + m_TelemetryMetricsService = telemetryMetricsService; + } + + public override void UpgradeDowngradeSubscription(string oldSku, string newSku, + GooglePlayProrationMode desiredProrationMode) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.UpgradeDowngradeSubscription(oldSku, newSku, desiredProrationMode), + TelemetryMetricDefinitions.upgradeDowngradeSubscriptionName); + } + + public override void RestoreTransactions(Action callback) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RestoreTransactions(callback), TelemetryMetricDefinitions.restoreTransactionName); + } + } +} diff --git a/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs.meta b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs.meta new file mode 100644 index 0000000..5c5d80a --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 94e7605481de41b882cadecbba3b3fc9 +timeCreated: 1649946287 \ No newline at end of file diff --git a/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs b/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs index 01253b5..1d945ba 100644 --- a/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs +++ b/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs @@ -26,7 +26,6 @@ internal class AppleStoreImpl : JSONStore, IAppleExtensions, IAppleConfiguration Action m_FetchStorePromotionVisibilitySuccess; private INativeAppleStore m_Native; ITelemetryDiagnostics m_TelemetryDiagnostics; - ITelemetryMetrics m_TelemetryMetrics; private static IUtil util; private static AppleStoreImpl instance; @@ -35,16 +34,14 @@ internal class AppleStoreImpl : JSONStore, IAppleExtensions, IAppleConfiguration private string products_json; - public AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) { + public AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics) { AppleStoreImpl.util = util; instance = this; m_TelemetryDiagnostics = telemetryDiagnostics; - m_TelemetryMetrics = telemetryMetrics; } public void SetNativeStore(INativeAppleStore apple) { base.SetNativeStore (apple); - base.SetTelemetryMetrics(m_TelemetryMetrics); this.m_Native = apple; apple.SetUnityPurchasingCallback (MessageCallback); } @@ -75,29 +72,24 @@ public bool simulateAskToBuy { } } - public void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) + public virtual void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) { - var fetchStorePromotionOrderMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.fetchStorePromotionOrderName); m_FetchStorePromotionOrderError = errorCallback; m_FetchStorePromotionOrderSuccess = successCallback; m_Native.FetchStorePromotionOrder(); - fetchStorePromotionOrderMetric.StopAndSendMetric(); } - public void FetchStorePromotionVisibility(Product product, Action successCallback, Action errorCallback) + public virtual void FetchStorePromotionVisibility(Product product, Action successCallback, Action errorCallback) { - var fetchStorePromotionVisibilityMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.fetchStorePromotionVisibilityName); m_FetchStorePromotionVisibilityError = errorCallback; m_FetchStorePromotionVisibilitySuccess = successCallback; m_Native.FetchStorePromotionVisibility(product.definition.id); - fetchStorePromotionVisibilityMetric.StopAndSendMetric(); } - public void SetStorePromotionOrder(List products) + public virtual void SetStorePromotionOrder(List products) { - var setStorePromotionOrderMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.setStorePromotionOrderName); // Encode product list as a json doc containing an array of store-specific ids: // { "products": [ "ssid1", "ssid2" ] } var productIds = new List(); @@ -108,12 +100,10 @@ public void SetStorePromotionOrder(List products) } var dict = new Dictionary{ { "products", productIds } }; m_Native.SetStorePromotionOrder(MiniJson.JsonEncode(dict)); - setStorePromotionOrderMetric.StopAndSendMetric(); } public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisibility visibility) { - var setStorePromotionVisibilityMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.setStorePromotionVisibilityName); if (product == null) { var ex = new ArgumentNullException(nameof(product)); @@ -121,7 +111,6 @@ public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisi throw ex; } m_Native.SetStorePromotionVisibility(product.definition.storeSpecificId, visibility.ToString()); - setStorePromotionVisibilityMetric.StopAndSendMetric(); } public string GetTransactionReceiptForProduct (Product product) { @@ -205,21 +194,17 @@ public override void OnProductsRetrieved (string json) m_Native.AddTransactionObserver (); } - public void RestoreTransactions(Action callback) + public virtual void RestoreTransactions(Action callback) { - var restoreTransactionMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.restoreTransactionName); m_RestoreCallback = callback; m_Native.RestoreTransactions (); - restoreTransactionMetric.StopAndSendMetric(); } - public void RefreshAppReceipt(Action successCallback, Action errorCallback) + public virtual void RefreshAppReceipt(Action successCallback, Action errorCallback) { - var refreshAppReceiptMetric = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.refreshAppReceiptName); m_RefreshReceiptSuccess = successCallback; m_RefreshReceiptError = errorCallback; m_Native.RefreshAppReceipt (); - refreshAppReceiptMetric.StopAndSendMetric(); } public void RegisterPurchaseDeferredListener(Action callback) @@ -227,11 +212,9 @@ public void RegisterPurchaseDeferredListener(Action callback) m_DeferredCallback = callback; } - public void ContinuePromotionalPurchases() + public virtual void ContinuePromotionalPurchases() { - var continuePromotionalPurchases = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.continuePromotionalPurchasesName); m_Native.ContinuePromotionalPurchases (); - continuePromotionalPurchases.StopAndSendMetric(); } public Dictionary GetIntroductoryPriceDictionary() { @@ -242,11 +225,9 @@ public Dictionary GetProductDetails() { return JSONSerializer.DeserializeProductDetails(this.products_json); } - public void PresentCodeRedemptionSheet() + public virtual void PresentCodeRedemptionSheet() { - var presentCodeRedemptionSheet = m_TelemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.presentCodeRedemptionSheetName); m_Native.PresentCodeRedemptionSheet(); - presentCodeRedemptionSheet.StopAndSendMetric(); } public void OnPurchaseDeferred(string productId) diff --git a/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs b/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs new file mode 100644 index 0000000..7bd4568 --- /dev/null +++ b/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Uniject; +using UnityEngine.Purchasing.Telemetry; + +namespace UnityEngine.Purchasing +{ + class MetricizedAppleStoreImpl : AppleStoreImpl + { + ITelemetryMetricsService m_TelemetryMetricsService; + + public MetricizedAppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics, + ITelemetryMetricsService telemetryMetricsService) : base(util, telemetryDiagnostics) + { + m_TelemetryMetricsService = telemetryMetricsService; + } + + public override void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.FetchStorePromotionOrder(successCallback, errorCallback), + TelemetryMetricDefinitions.fetchStorePromotionOrderName); + } + + public override void FetchStorePromotionVisibility(Product product, + Action successCallback, Action errorCallback) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.FetchStorePromotionVisibility(product, successCallback, errorCallback), + TelemetryMetricDefinitions.fetchStorePromotionVisibilityName); + } + + public override void SetStorePromotionOrder(List products) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.SetStorePromotionOrder(products), TelemetryMetricDefinitions.setStorePromotionOrderName); + } + + public override void RestoreTransactions(Action callback) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RestoreTransactions(callback), TelemetryMetricDefinitions.restoreTransactionName); + } + + public override void RefreshAppReceipt(Action successCallback, Action errorCallback) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RefreshAppReceipt(successCallback, errorCallback), + TelemetryMetricDefinitions.refreshAppReceiptName); + } + + public override void ContinuePromotionalPurchases() + { + m_TelemetryMetricsService.ExecuteTimedAction( + base.ContinuePromotionalPurchases, TelemetryMetricDefinitions.continuePromotionalPurchasesName); + } + + public override void PresentCodeRedemptionSheet() + { + m_TelemetryMetricsService.ExecuteTimedAction( + base.PresentCodeRedemptionSheet, TelemetryMetricDefinitions.presentCodeRedemptionSheetName); + } + + public override void RetrieveProducts(ReadOnlyCollection products) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RetrieveProducts(products), + TelemetryMetricDefinitions.retrieveProductsName); + } + + public override void Purchase(ProductDefinition product, string developerPayload) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.Purchase(product, developerPayload), TelemetryMetricDefinitions.initPurchaseName); + } + } +} diff --git a/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs.meta b/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs.meta new file mode 100644 index 0000000..460b535 --- /dev/null +++ b/Runtime/Stores/AppleAppStore/MetricizedAppleStoreImpl.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 793911e69df141debfb65196c1a88c7e +timeCreated: 1649886506 \ No newline at end of file diff --git a/Runtime/Stores/BaseStore/JSONStore.cs b/Runtime/Stores/BaseStore/JSONStore.cs index 97072c3..0ec575f 100644 --- a/Runtime/Stores/BaseStore/JSONStore.cs +++ b/Runtime/Stores/BaseStore/JSONStore.cs @@ -49,7 +49,6 @@ public Product[] storeCatalog { private INativeStore m_Store; private List m_StoreCatalog; private bool m_IsRefreshing; - ITelemetryMetrics m_TelemetryMetrics; private Action m_RefreshCallback; @@ -78,11 +77,6 @@ public void SetNativeStore(INativeStore native) this.m_Store = native; } - public void SetTelemetryMetrics(ITelemetryMetrics telemetryMetrics) - { - m_TelemetryMetrics = telemetryMetrics; - } - void IStoreInternal.SetModule(StandardPurchasingModule module) { if(module == null) @@ -119,9 +113,7 @@ public override void Initialize (IStoreCallback callback) public override void RetrieveProducts (ReadOnlyCollection products) { - var retrieveProductsMetric = m_TelemetryMetrics?.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.retrieveProductsName); m_Store.RetrieveProducts(JSONSerializer.SerializeProductDefs(products)); - retrieveProductsMetric?.StopAndSendMetric(); } internal void ProcessManagedStoreResponse(List storeProducts) @@ -147,9 +139,7 @@ internal void ProcessManagedStoreResponse(List storeProducts) public override void Purchase (UnityEngine.Purchasing.ProductDefinition product, string developerPayload) { - var initPurchaseMetric = m_TelemetryMetrics?.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.initPurchaseName); m_Store.Purchase (JSONSerializer.SerializeProductDef (product), developerPayload); - initPurchaseMetric?.StopAndSendMetric(); } public override void FinishTransaction (UnityEngine.Purchasing.ProductDefinition product, string transactionId) diff --git a/Runtime/Stores/BaseStore/MetricizedJsonStore.cs b/Runtime/Stores/BaseStore/MetricizedJsonStore.cs new file mode 100644 index 0000000..97a6437 --- /dev/null +++ b/Runtime/Stores/BaseStore/MetricizedJsonStore.cs @@ -0,0 +1,29 @@ +using System.Collections.ObjectModel; +using UnityEngine.Purchasing.Telemetry; + +namespace UnityEngine.Purchasing +{ + class MetricizedJsonStore : JSONStore + { + ITelemetryMetricsService m_TelemetryMetricsService; + + public MetricizedJsonStore(ITelemetryMetricsService telemetryMetricsService) + { + m_TelemetryMetricsService = telemetryMetricsService; + } + + public override void RetrieveProducts(ReadOnlyCollection products) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.RetrieveProducts(products), + TelemetryMetricDefinitions.retrieveProductsName); + } + + public override void Purchase(ProductDefinition product, string developerPayload) + { + m_TelemetryMetricsService.ExecuteTimedAction( + () => base.Purchase(product, developerPayload), + TelemetryMetricDefinitions.initPurchaseName); + } + } +} diff --git a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta b/Runtime/Stores/BaseStore/MetricizedJsonStore.cs.meta similarity index 83% rename from Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta rename to Runtime/Stores/BaseStore/MetricizedJsonStore.cs.meta index 2becb3e..28ff567 100644 --- a/Runtime/Purchasing/Analytics/EmptyUnityAnalytics.cs.meta +++ b/Runtime/Stores/BaseStore/MetricizedJsonStore.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 588a7493bf6bcb64eb06d3444a15a5a2 +guid: d20e9c28bb2b4b8bbd16889654c4617c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index 906a0c1..399a7f3 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -239,7 +239,8 @@ private IStore InstantiateAndroid() } else { - var store = new JSONStore (); + var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); + var store = new MetricizedJsonStore(telemetryMetrics); return InstantiateAndroidHelper(store); } } @@ -255,12 +256,12 @@ private IStore InstantiateGoogleStore() IGoogleFetchPurchases googleFetchPurchases = new GoogleFetchPurchases(googlePlayStoreService, googlePlayStoreFinishTransactionService); var googlePlayConfiguration = BuildGooglePlayStoreConfiguration(googlePlayStoreService, googlePurchaseCallback); var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); - var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); + var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); IGooglePlayStoreRetrieveProductsService googlePlayStoreRetrieveProductsService = new GooglePlayStoreRetrieveProductsService( googlePlayStoreService, googleFetchPurchases, googlePlayConfiguration); - var googlePlayStoreExtensions = BuildGooglePlayStoreExtensions( + var googlePlayStoreExtensions = new MetricizedGooglePlayStoreExtensions( googlePlayStoreService, googlePlayStoreFinishTransactionService, telemetryDiagnostics, @@ -286,12 +287,6 @@ void BindGoogleExtension(GooglePlayStoreExtensions googlePlayStoreExtensions) BindExtension(googlePlayStoreExtensions); } - static GooglePlayStoreExtensions BuildGooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetrics telemetryMetrics) - { - GooglePlayStoreExtensions googlePlayStoreExtensions = new GooglePlayStoreExtensions(googlePlayStoreService, googlePlayStoreFinishTransactionService, telemetryDiagnostics, telemetryMetrics); - return googlePlayStoreExtensions; - } - static GooglePlayConfiguration BuildGooglePlayStoreConfiguration(IGooglePlayStoreService googlePlayStoreService, IGooglePurchaseCallback googlePurchaseCallback) { GooglePlayConfiguration googlePlayConfiguration = new GooglePlayConfiguration(googlePlayStoreService); @@ -319,11 +314,11 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g var finishTransactionService = new GoogleFinishTransactionService(googleBillingClient, queryPurchasesService); var billingClientStateListener = new BillingClientStateListener(); var priceChangeService = new GooglePriceChangeService(googleBillingClient, googleQuerySkuDetailsService); - var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); + var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); googlePurchaseUpdatedListener.SetGoogleQueryPurchaseService(queryPurchasesService); - return new GooglePlayStoreService( + return new MetricizedGooglePlayStoreService( googleBillingClient, googleQuerySkuDetailsService, purchaseService, @@ -347,9 +342,7 @@ private IStore InstantiateUDP() private IStore InstantiateAndroidHelper (JSONStore store) { - var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); store.SetNativeStore (GetAndroidNativeStore(store)); - store.SetTelemetryMetrics(telemetryMetrics); return store; } @@ -377,11 +370,10 @@ private IStore InstantiateGooglePlayBilling() private IStore InstantiateApple () { var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); - var telemetryMetrics = new TelemetryMetrics(telemetryMetricsInstanceWrapper); - var store = new AppleStoreImpl (util, telemetryDiagnostics, telemetryMetrics); + var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); + var store = new MetricizedAppleStoreImpl(util, telemetryDiagnostics, telemetryMetrics); var appleBindings = m_NativeStoreProvider.GetStorekit (store); store.SetNativeStore (appleBindings); - store.SetTelemetryMetrics(telemetryMetrics); BindExtension (store); return store; } diff --git a/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs index ba1db5f..1a47ed9 100644 --- a/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs +++ b/Runtime/Stores/Telemetry/IapCoreInitializeCallback.cs @@ -20,20 +20,25 @@ static void Register() public Task Initialize(CoreRegistry registry) { var metricsInstanceWrapper = StandardPurchasingModule.Instance().telemetryMetricsInstanceWrapper; + var diagnosticsInstanceWrapper = StandardPurchasingModule.Instance().telemetryDiagnosticsInstanceWrapper; - ITelemetryMetrics telemetryMetrics = new TelemetryMetrics(metricsInstanceWrapper); - var packageInitTimeMetric = telemetryMetrics.CreateAndStartMetricEvent(TelemetryMetricTypes.Histogram, TelemetryMetricNames.packageInitTimeName); + ITelemetryMetricsService telemetryMetricsService = new TelemetryMetricsService(metricsInstanceWrapper); + telemetryMetricsService.ExecuteTimedAction( + () => InitializeTelemetryComponents(metricsInstanceWrapper, diagnosticsInstanceWrapper), + TelemetryMetricDefinitions.packageInitTimeName + ); - var diagnosticsInstanceWrapper = StandardPurchasingModule.Instance().telemetryDiagnosticsInstanceWrapper; + return Task.CompletedTask; + } + + private static void InitializeTelemetryComponents(ITelemetryMetricsInstanceWrapper metricsInstanceWrapper, + ITelemetryDiagnosticsInstanceWrapper diagnosticsInstanceWrapper) + { var diagnosticsFactory = CoreRegistry.Instance.GetServiceComponent(); diagnosticsInstanceWrapper.SetDiagnosticsInstance(diagnosticsFactory.Create(k_PurchasingPackageName)); var metricsFactory = CoreRegistry.Instance.GetServiceComponent(); metricsInstanceWrapper.SetMetricsInstance(metricsFactory.Create(k_PurchasingPackageName)); - - packageInitTimeMetric.StopAndSendMetric(); - - return Task.CompletedTask; } } } diff --git a/Samples~/04 IntegratingSelfProvidedBackendReceiptValidation/README.md b/Samples~/04 IntegratingSelfProvidedBackendReceiptValidation/README.md index 39d0c14..75b9692 100644 --- a/Samples~/04 IntegratingSelfProvidedBackendReceiptValidation/README.md +++ b/Samples~/04 IntegratingSelfProvidedBackendReceiptValidation/README.md @@ -7,7 +7,7 @@ the [documentation](https://docs.unity3d.com/Manual/UnityIAPProcessingPurchases. This sample uses a mock for the backend implementation. You can plug in your own backend by replacing the `MockServerSideValidation` method in `IntegratingSelfProvidedBackendReceiptValidation.cs`. For more information about how to do a web request in unity, see -the [documentation](https://docs.unity3d.com/2021.2/Documentation/ScriptReference/Networking.UnityWebRequest.Post.html). +the [documentation](https://docs.unity3d.com/2021.3/Documentation/ScriptReference/Networking.UnityWebRequest.Post.html). This sample uses a fake store for its transactions, to use a real store like the App Store or the Google Play Store, you would need to register your application and add In-App Purchases. For more information, follow the documentation for one diff --git a/Samples~/06 InitializeGamingServices.meta b/Samples~/06 InitializeGamingServices.meta new file mode 100644 index 0000000..07facb6 --- /dev/null +++ b/Samples~/06 InitializeGamingServices.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a5b9493c79bf445fa69fc8f21787e8bd +timeCreated: 1650900975 \ No newline at end of file diff --git a/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs new file mode 100644 index 0000000..52bfc63 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs @@ -0,0 +1,62 @@ +using System; +using Unity.Services.Core; +using Unity.Services.Core.Environments; +using UnityEngine; +using UnityEngine.UI; + +namespace Samples.Purchasing.Core.InitializeGamingServices +{ + public class InitializeGamingServices : MonoBehaviour + { + public Text informationText; + + const string k_Environment = "production"; + + void Awake() + { + // Uncomment this line to initialize Unity Gaming Services. + // Initialize(OnSuccess, OnError); + } + + void Initialize(Action onSuccess, Action onError) + { + try + { + var options = new InitializationOptions().SetEnvironmentName(k_Environment); + + UnityServices.InitializeAsync(options).ContinueWith(task => onSuccess()); + } + catch (Exception exception) + { + onError(exception.Message); + } + } + + void OnSuccess() + { + var text = "Congratulations!\nUnity Gaming Services has been successfully initialized."; + informationText.text = text; + Debug.Log(text); + } + + void OnError(string message) + { + var text = $"Unity Gaming Services failed to initialize with error: {message}."; + informationText.text = text; + Debug.LogError(text); + } + + void Start() + { + if (UnityServices.State == ServicesInitializationState.Uninitialized) + { + var text = + "Error: Unity Gaming Services not initialized.\n" + + "To initialize Unity Gaming Services, open the file \"InitializeGamingServices.cs\" " + + "and uncomment the line \"Initialize(OnSuccess, OnError);\" in the \"Awake\" method."; + informationText.text = text; + Debug.LogError(text); + } + } + } +} diff --git a/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs.meta b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs.meta new file mode 100644 index 0000000..5d52079 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d070cf48df2d4d3db819987c544aa328 +timeCreated: 1650900975 \ No newline at end of file diff --git a/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity b/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity new file mode 100644 index 0000000..d62c9e5 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity @@ -0,0 +1,498 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 9 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 3 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GIWorkflowMode: 1 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 0 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_FinalGather: 0 + m_FinalGatherFiltering: 1 + m_FinalGatherRayCount: 256 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 2 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + accuratePlacement: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &155948022 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 155948025} + - component: {fileID: 155948024} + - component: {fileID: 155948023} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!81 &155948023 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 155948022} + m_Enabled: 1 +--- !u!20 &155948024 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 155948022} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_FocalLength: 50 + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 1 + orthographic size: 5 + m_Depth: -1 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &155948025 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 155948022} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: -10} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &995404323 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 995404324} + - component: {fileID: 995404326} + - component: {fileID: 995404325} + m_Layer: 5 + m_Name: Information Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &995404324 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 995404323} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 1360405277} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &995404325 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 995404323} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0} + m_FontSize: 45 + m_FontStyle: 0 + m_BestFit: 0 + m_MinSize: 4 + m_MaxSize: 45 + m_Alignment: 4 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: ' To initialize Unity Gaming Services, open the file "InitializeGamingServices.cs + and uncomment the line "Initialize(OnSuccess, OnError);" in the "Awake" method.' +--- !u!222 &995404326 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 995404323} + m_CullTransparentMesh: 1 +--- !u!1 &1083604101 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1083604103} + - component: {fileID: 1083604102} + m_Layer: 0 + m_Name: Initialize Gaming Services + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1083604102 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1083604101} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d070cf48df2d4d3db819987c544aa328, type: 3} + m_Name: + m_EditorClassIdentifier: + informationText: {fileID: 995404325} +--- !u!4 &1083604103 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1083604101} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 1 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1205668041 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1205668044} + - component: {fileID: 1205668043} + - component: {fileID: 1205668042} + m_Layer: 0 + m_Name: EventSystem + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1205668042 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1205668041} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4f231c4fb786f3946a6b90b886c48677, type: 3} + m_Name: + m_EditorClassIdentifier: + m_HorizontalAxis: Horizontal + m_VerticalAxis: Vertical + m_SubmitButton: Submit + m_CancelButton: Cancel + m_InputActionsPerSecond: 10 + m_RepeatDelay: 0.5 + m_ForceModuleActive: 0 +--- !u!114 &1205668043 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1205668041} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3} + m_Name: + m_EditorClassIdentifier: + m_FirstSelected: {fileID: 0} + m_sendNavigationEvents: 1 + m_DragThreshold: 10 +--- !u!4 &1205668044 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1205668041} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 0} + m_RootOrder: 3 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1360405273 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1360405277} + - component: {fileID: 1360405276} + - component: {fileID: 1360405275} + - component: {fileID: 1360405274} + m_Layer: 5 + m_Name: Canvas + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1360405274 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360405273} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3} + m_Name: + m_EditorClassIdentifier: + m_IgnoreReversedGraphics: 1 + m_BlockingObjects: 0 + m_BlockingMask: + serializedVersion: 2 + m_Bits: 4294967295 +--- !u!114 &1360405275 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360405273} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3} + m_Name: + m_EditorClassIdentifier: + m_UiScaleMode: 0 + m_ReferencePixelsPerUnit: 100 + m_ScaleFactor: 1 + m_ReferenceResolution: {x: 800, y: 600} + m_ScreenMatchMode: 0 + m_MatchWidthOrHeight: 0 + m_PhysicalUnit: 3 + m_FallbackScreenDPI: 96 + m_DefaultSpriteDPI: 96 + m_DynamicPixelsPerUnit: 1 + m_PresetInfoIsWorld: 0 +--- !u!223 &1360405276 +Canvas: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360405273} + m_Enabled: 1 + serializedVersion: 3 + m_RenderMode: 0 + m_Camera: {fileID: 0} + m_PlaneDistance: 100 + m_PixelPerfect: 0 + m_ReceivesEvents: 1 + m_OverrideSorting: 0 + m_OverridePixelPerfect: 0 + m_SortingBucketNormalizedSize: 0 + m_AdditionalShaderChannelsFlag: 0 + m_SortingLayerID: 0 + m_SortingOrder: 0 + m_TargetDisplay: 0 +--- !u!224 &1360405277 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1360405273} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 0, y: 0, z: 0} + m_Children: + - {fileID: 995404324} + m_Father: {fileID: 0} + m_RootOrder: 2 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0, y: 0} diff --git a/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity.meta b/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity.meta new file mode 100644 index 0000000..05c0131 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/InitializeGamingServices.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6fe7631e676014f379c5ee36e4d946a3 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/06 InitializeGamingServices/README.md b/Samples~/06 InitializeGamingServices/README.md new file mode 100644 index 0000000..a43c622 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/README.md @@ -0,0 +1,6 @@ +## README - In-App Purchasing Sample Scenes - Initialize Unity Gaming Services + +This sample showcases how to initialize [Unity Gaming Services](https://unity.com/solutions/gaming-services) using the [Services Core API](https://docs.unity.com/ugs-overview/services-core-api.html) + +### Unity Gaming Services +Enabling Unity Gaming Services within your game will let the In-App Purchasing package send transaction analytics events through the new [Unity Analytics](https://unity.com/products/unity-analytics) service. diff --git a/Samples~/06 InitializeGamingServices/README.md.meta b/Samples~/06 InitializeGamingServices/README.md.meta new file mode 100644 index 0000000..6abc030 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/README.md.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9e51c612c3804089950de0a6933851ad +timeCreated: 1650900992 \ No newline at end of file diff --git a/package.json b/package.json index c76add0..59f23e3 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,9 @@ "displayName": "In App Purchasing", "unity": "2020.3", "_upm": { - "gameService": { - "groupIndex": 4, - "groupName": "Monetize", - "configurePath": "Project/Services/In-App Purchasing", - "genericDashboardUrl": "https://unity3d.com/unity/features/iap" - }, - "supportedPlatforms": [ - "Android", - "iOS" - ] + "changelog": "### Added\n- Support for Unity Analytics TransactionFailed event.\n- Sample showcasing how to initialize [Unity Gaming Services](https://unity.com/solutions/gaming-services) using the [Services Core API](https://docs.unity.com/ugs-overview/services-core-api.html)\n\n### Changed\n- The Analytics notice in the In-App Purchasing service window has been removed for Unity Editors 2022 and up." }, - "version": "4.2.0-pre.1", + "version": "4.2.0-pre.2", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -23,7 +14,7 @@ "com.unity.modules.jsonserialize": "1.0.0", "com.unity.modules.androidjni": "1.0.0", "com.unity.services.core": "1.3.1", - "com.unity.services.analytics": "4.0.0-pre.1" + "com.unity.services.analytics": "4.0.0-pre.2" }, "keywords": [ "purchasing", @@ -32,19 +23,16 @@ ], "license": "Unity Companion Package License v1.0", "hideInEditor": false, - "upm": { - "changelog": "### Added\n- Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction).\n- The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package.\n\n### Changed\n- The minimum Unity Editor version supported is 2020.3.\n- The In-App Purchasing service window now links to the [new Unity Dashboard](https://dashboard.unity3d.com/) for Unity Editors 2022 and up.\n\n### Fixed\n- GooglePlay - Fixed OnInitializeFailed never called if GooglePlay BillingClient is not ready during initialization.\n- GooglePlay - GoogleBilling is allowed to initialize correctly even if the user's Google account is logged out, so long as it is linked. The user will need to log in to their account to continue making purchases.\n- Fixed a build error `DirectoryNotFoundException` that occurred when the build platform was iOS or tvOS and the build target was another platform." - }, "relatedPackages": { - "com.unity.purchasing.tests": "4.2.0-pre.1" + "com.unity.purchasing.tests": "4.2.0-pre.2" }, "upmCi": { - "footprint": "45c6cd469210712b24e54a933882ed9f294cee0f" + "footprint": "e5c58b5ba0a54e9b6ab4dc8e4ce89ef8cd8c7680" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "e75af968366fd36fded712b3cb4f41e83df8f5a7" + "revision": "64dd87f37c44581d6f8ba38181a7e3661b01999b" }, "samples": [ { From d9610d5910aedc5746e321cb1c6ac41e5d22b6db Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Tue, 14 Jun 2022 00:00:00 +0000 Subject: [PATCH 03/12] com.unity.purchasing@4.2.1 ## [4.2.1] - 2022-06-14 ### Fixed --- CHANGELOG-ASSETSTORE.md | 80 +- CHANGELOG.md | 51 +- Documentation~/AmazonTesting.md | 10 +- Documentation~/AppleReceipt.md | 2 +- Documentation~/AppleTesting.md | 2 - Documentation~/BackendReceiptValidation.md | 2 +- Documentation~/DefiningProductsCoded.md | 2 +- Documentation~/DefiningProductsOverview.md | 7 +- Documentation~/GettingStarted.md | 2 +- Documentation~/GooglePublicKey.md | 2 +- Documentation~/GoogleReceipt.md | 2 +- Documentation~/HowToTest.md | 2 +- Documentation~/IAPListener.md | 2 +- Documentation~/InitializationOverview.md | 6 + Documentation~/Overview.md | 2 +- Documentation~/StoresSupported.md | 2 +- Documentation~/TableOfContents.md | 6 +- Documentation~/UnityIAPAmazonConfiguration.md | 11 +- .../UnityIAPAmazonExtendedFunctionality.md | 3 +- Documentation~/UnityIAPAppleConfiguration.md | 52 +- Documentation~/UnityIAPBrowsingMetadata.md | 1 - Documentation~/UnityIAPDefiningProducts.md | 10 +- .../UnityIAPFetchingProductsIncrementally.md | 40 +- Documentation~/UnityIAPGoogleConfiguration.md | 30 +- Documentation~/UnityIAPGooglePlay.md | 2 +- .../UnityIAPHandlingPurchaseFailures.md | 1 - .../UnityIAPIStoreHandlingPurchases.md | 3 - .../UnityIAPIStoreInitialization.md | 1 - .../UnityIAPIStoreRetrievingProducts.md | 1 - Documentation~/UnityIAPImplementingAStore.md | 5 +- .../UnityIAPInitializeUnityGamingServices.md | 32 +- ...tyIAPInitializeUnityGamingServices.md.meta | 3 - Documentation~/UnityIAPInitiatingPurchases.md | 1 - Documentation~/UnityIAPModuleConfiguration.md | 1 - Documentation~/UnityIAPModuleExtension.md | 2 - Documentation~/UnityIAPModuleRegistration.md | 2 - Documentation~/UnityIAPModules.md | 1 - Documentation~/UnityIAPProcessingPurchases.md | 2 - Documentation~/UnityIAPPurchaseReceipts.md | 2 +- .../UnityIAPRestoringTransactions.md | 17 +- Documentation~/UnityIAPUniversalWindows.md | 1 - Documentation~/UnityIAPValidatingReceipts.md | 48 +- .../UnityIAPWindowsConfiguration.md | 36 +- Documentation~/UnityIAPiOSMAS.md | 66 +- Documentation~/WhatCustomStore.md | 1 - Documentation~/WhatIsFakeStore.md | 2 +- Documentation~/images/AutoInitialize.png | Bin 11848 -> 38223 bytes Documentation~/images/AutoInitializeUGS.png | Bin 0 -> 97322 bytes .../images/UGSInitializationFlowDiagram.png | Bin 0 -> 6804 bytes Editor/Analytics/Entity/EventUINames.cs | 1 + ...icEditorClickCheckboxEventSenderHelpers.cs | 5 + Editor/AppStoreExtensionMethods.cs | 4 +- Editor/ApplePriceTiers.cs | 150 +- Editor/AppleXMLProductCatalogExporter.cs | 136 +- Editor/AssemblyInfo.cs | 11 +- Editor/BuildTargetGroupExtensions.cs | 42 +- Editor/GooglePlayProductCatalogExporter.cs | 576 ++-- Editor/IAPButtonEditor.cs | 94 +- Editor/MenuItems/IapButtonMenu.cs | 2 +- Editor/MenuItems/IapListenerMenu.cs | 5 +- .../Service/ObfuscationGenerator.cs | 2 +- .../Service/ObfuscationMigration.cs | 4 +- .../Obfuscation/Service/TangleObfuscator.cs | 12 +- Editor/Obfuscation/UI/ObfuscatorWindow.cs | 6 +- Editor/ProductCatalogEditor.cs | 238 +- Editor/RichEditorWindow.cs | 2 +- .../Entity/Consts/UIResourceUtils.cs | 1 + .../Presenter/BasePurchasingState.cs | 1 + .../SimpleStateMachine.cs | 2 +- .../UI/UXML/AnalyticsWarning.uxml | 9 + .../UI/UXML/AnalyticsWarning.uxml.meta | 3 + .../AnalyticsWarningServiveSettingsBlock.cs | 22 + ...alyticsWarningServiveSettingsBlock.cs.meta | 3 + .../GooglePlayConfigurationSettingsBlock.cs | 3 +- Editor/UdpInstaller.cs | 2 +- Editor/UdpSynchronizationApi.cs | 10 +- Editor/UnityPurchasingEditor.cs | 39 +- Editor/WebRequest/CloudProjectWebRequest.cs | 2 +- .../iOS/UnityEarlyTransactionObserver.h | 7 +- .../iOS/UnityEarlyTransactionObserver.mm | 70 +- Plugins/UnityPurchasing/iOS/UnityPurchasing.h | 20 +- Plugins/UnityPurchasing/iOS/UnityPurchasing.m | 831 ++--- .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes README.md | 8 +- Runtime/Apple/iOSStoreBindings.cs | 93 +- Runtime/AppleCore/AssemblyInfo.cs | 2 +- Runtime/AppleCore/INativeAppleStore.cs | 6 +- Runtime/AppleMacos/OSXStoreBindings.cs | 95 +- Runtime/AppleMacosStub/AssemblyInfo.cs | 2 +- Runtime/AppleMacosStub/OSXStoreBindings.cs | 70 +- Runtime/AppleStub/AssemblyInfo.cs | 2 +- Runtime/AppleStub/iOSStoreBindings.cs | 69 +- Runtime/Codeless/AssemblyInfo.cs | 2 +- Runtime/Codeless/CodelessIAPStoreListener.cs | 36 +- Runtime/Codeless/IAPButton.cs | 3 +- .../UnityEngine.Purchasing.Codeless.asmdef | 3 +- Runtime/Common/INativeStore.cs | 38 +- Runtime/Common/MiniJSON.cs | 956 +++--- Runtime/Common/VersionCheck.cs | 247 +- .../Purchasing/Analytics/AnalyticsAdapter.cs | 2 +- .../Analytics/Legacy/LegacyUnityAnalytics.cs | 5 +- Runtime/Purchasing/ConfigurationBuilder.cs | 13 +- Runtime/Purchasing/CoreServices.meta | 3 + .../Purchasing/CoreServices/Interfaces.meta | 3 + .../IUnityServicesInitializationChecker.cs | 9 + ...UnityServicesInitializationChecker.cs.meta | 3 + .../UnityServicesInitializationChecker.cs | 39 + ...UnityServicesInitializationChecker.cs.meta | 3 + Runtime/Purchasing/PayoutDefinition.cs | 60 +- Runtime/Purchasing/ProductDefinition.cs | 12 +- Runtime/Purchasing/PurchasingFactory.cs | 32 +- Runtime/Purchasing/PurchasingManager.cs | 12 +- Runtime/Purchasing/SimpleCatalogProvider.cs | 29 +- Runtime/Purchasing/UnifiedReceipt.cs | 2 +- Runtime/Purchasing/UnityPurchasing.cs | 40 +- .../Purchasing/Utilites/ILoggerExtensions.cs | 17 + .../Utilites/ILoggerExtensions.cs.meta | 3 + .../Utilites/ProductPurchaseUpdater.cs | 2 +- Runtime/Security/AppleValidator.cs | 216 +- Runtime/Security/Asn1Processor/Asn1Node.cs | 2702 ++++++++--------- Runtime/Security/Asn1Processor/Asn1Parser.cs | 350 ++- Runtime/Security/Asn1Processor/Asn1Tag.cs | 72 +- Runtime/Security/Asn1Processor/Asn1Util.cs | 1472 ++++----- Runtime/Security/Asn1Processor/IAsn1Node.cs | 378 +-- Runtime/Security/Asn1Processor/Oid.cs | 287 +- Runtime/Security/Asn1Processor/RelativeOid.cs | 120 +- Runtime/Security/Asn1Processor/Util.cs | 128 +- Runtime/Security/AssemblyInfo.cs | 4 +- Runtime/Security/Certificate.cs | 391 +-- Runtime/Security/CrossPlatformValidator.cs | 190 +- Runtime/Security/GooglePlayReceipt.cs | 49 +- Runtime/Security/GooglePlayValidator.cs | 64 +- Runtime/Security/Obfuscator.cs | 6 +- Runtime/Security/PKCS7.cs | 328 +- Runtime/Security/RSAPubKey.cs | 123 +- Runtime/SecurityCore/AppleReceipt.cs | 61 +- Runtime/SecurityCore/AssemblyInfo.cs | 8 +- Runtime/SecurityCore/IAPSecurityException.cs | 15 +- Runtime/SecurityCore/IPurchaseReceipt.cs | 20 +- Runtime/SecurityStub/AppleValidator.cs | 38 +- Runtime/SecurityStub/AssemblyInfo.cs | 4 +- .../SecurityStub/CrossPlatformValidator.cs | 2 +- Runtime/SecurityStub/GooglePlayReceipt.cs | 32 +- Runtime/SecurityStub/Obfuscator.cs | 6 +- .../AmazonAppStoreStoreExtensions.cs | 45 +- .../Android/AmazonAppStore/AmazonApps.cs | 14 +- .../Android/AmazonAppStore/FakeAmazon.cs | 19 +- .../AmazonAppStore/IAmazonConfiguration.cs | 18 +- .../AmazonAppStore/IAmazonExtensions.cs | 20 +- Runtime/Stores/Android/AndroidJavaStore.cs | 49 +- Runtime/Stores/Android/AndroidStore.cs | 2 +- .../AAR/GoogleCachedQuerySkuDetailsService.cs | 2 +- .../AAR/GoogleLastKnownProductService.cs | 2 +- .../GooglePlay/AAR/GooglePlayStoreService.cs | 46 +- .../AAR/Interfaces/IGoogleBillingResult.cs | 6 +- .../GooglePurchaseUpdatedListener.cs | 2 +- .../Listeners/SkuDetailsResponseListener.cs | 2 +- .../AAR/Models/GoogleBillingClient.cs | 4 +- .../AAR/Models/GoogleBillingResult.cs | 2 +- .../AAR/Models/GooglePurchaseResult.cs | 2 +- .../AAR/Utils/AndroidJavaObjectWrapper.cs | 2 +- .../AAR/Utils/GoogleReceiptEncoder.cs | 3 +- .../FakeGooglePlayStoreExtensions.cs | 2 +- .../Stores/Android/GooglePlay/GooglePlay.cs | 10 +- .../GooglePlay/GooglePlayConfiguration.cs | 2 +- .../GooglePlay/GooglePlayPurchaseCallback.cs | 2 +- .../Android/GooglePlay/GooglePlayStore.cs | 2 +- .../GooglePlay/GooglePlayStoreExtensions.cs | 4 +- .../Interfaces/IGooglePlayConfiguration.cs | 2 +- .../Interfaces/IGooglePlayStoreExtensions.cs | 8 +- .../Stores/Android/GooglePlay/package.json | 7 + .../Android/GooglePlay/package.json.meta | 7 + .../Stores/Android/IAndroidStoreSelection.cs | 16 +- Runtime/Stores/Android/IUnityCallback.cs | 21 +- Runtime/Stores/Android/JSONSerializer.cs | 92 +- Runtime/Stores/Android/JavaBridge.cs | 60 +- .../Stores/Android/ScriptingStoreCallback.cs | 32 +- .../Stores/Android/ScriptingUnityCallback.cs | 62 +- .../Stores/Android/UDP/FakeUDPExtension.cs | 2 +- Runtime/Stores/Android/UDP/INativeUDPStore.cs | 2 +- Runtime/Stores/Android/UDP/IUDPExtensions.cs | 2 +- Runtime/Stores/Android/UDP/UDP.cs | 2 +- Runtime/Stores/Android/UDP/UDPBindings.cs | 33 +- Runtime/Stores/Android/UDP/UDPImpl.cs | 12 +- .../Stores/Android/UDP/UDPReflectionUtil.cs | 2 +- Runtime/Stores/AppStore.cs | 2 +- Runtime/Stores/AppleAppStore/AppleAppStore.cs | 10 +- .../Stores/AppleAppStore/AppleStoreImpl.cs | 254 +- .../AppleAppStore/FakeAppleConfiguration.cs | 39 +- .../AppleAppStore/FakeAppleExtensions.cs | 15 +- .../AppleAppStore/IAppleConfiguration.cs | 45 +- .../Stores/AppleAppStore/IAppleExtensions.cs | 4 +- Runtime/Stores/AppleAppStore/MacAppStore.cs | 10 +- Runtime/Stores/AssemblyInfo.cs | 2 +- .../Stores/BaseStore/INativeStoreProvider.cs | 2 +- Runtime/Stores/BaseStore/JSONStore.cs | 62 +- .../Stores/BaseStore/NativeStoreProvider.cs | 24 +- Runtime/Stores/FakeStore/DialogRequest.cs | 2 +- Runtime/Stores/FakeStore/FakeStore.cs | 25 +- Runtime/Stores/FakeStore/IFakeExtensions.cs | 10 +- Runtime/Stores/FakeStore/UIFakeStore.cs | 344 +-- Runtime/Stores/Networking/QueryHelper.cs | 5 +- Runtime/Stores/ProductCatalog.cs | 134 +- Runtime/Stores/StandardPurchasingModule.cs | 157 +- Runtime/Stores/StoreConfiguration.cs | 55 +- .../Stores/StoreSpecificPurchaseErrorCode.cs | 2 +- Runtime/Stores/SubscriptionManager.cs | 419 ++- .../FakeTransactionHistoryExtensions.cs | 2 +- .../ITransactionHistoryExtensions.cs | 2 +- Runtime/Stores/Util/FileReference.cs | 44 +- Runtime/Stores/Util/IUtil.cs | 2 +- .../Util/ProductDefinitionExtensions.cs | 2 +- Runtime/Stores/Util/UnityUtil.cs | 373 +-- .../WindowsStore/FakeMicrosoftExtensions.cs | 14 +- .../WindowsStore/IMicrosoftConfiguration.cs | 10 +- .../WindowsStore/IMicrosoftExtensions.cs | 8 +- Runtime/Stores/WindowsStore/WinRTStore.cs | 314 +- Runtime/Stores/WindowsStore/WindowsStore.cs | 16 +- Runtime/WinRT/AssemblyInfo.cs | 2 +- Runtime/WinRT/CurrentApp.cs | 2 +- Runtime/WinRT/CurrentAppSimulator.cs | 34 +- Runtime/WinRT/Factory.cs | 2 +- Runtime/WinRT/ICurrentApp.cs | 2 +- Runtime/WinRT/WinRTStore.cs | 107 +- Runtime/WinRT/XMLUtils.cs | 5 +- Runtime/WinRTCore/AssemblyInfo.cs.cs | 2 +- Runtime/WinRTCore/IWinRT.cs | 2 +- Runtime/WinRTCore/WinProductDescription.cs | 11 +- Runtime/WinRTStub/AssemblyInfo.cs | 2 +- Runtime/WinRTStub/Factory.cs | 3 +- .../01 BuyingConsumables/BuyingConsumables.cs | 2 +- .../BuyingSubscription.cs | 2 +- Samples~/02 BuyingSubscription/README.md | 4 +- .../FetchingAdditionalProducts.cs | 2 +- .../LocalReceiptValidation.cs | 2 +- .../06 InitializeGamingServices/.sample.json | 5 + .../InitializeGamingServices.cs | 2 +- .../README.md | 2 +- .../RefreshingAppReceipt.cs | 2 +- .../RestoringTransactions.cs | 2 +- .../HandlingDeferredPurchases.cs | 2 +- .../RetrievingProductReceipt.cs | 2 +- .../FraudDetection.cs | 2 +- .../GettingIntroductoryPrices.cs | 2 +- .../GettingProductDetails.cs | 2 +- .../PromotingProducts.cs | 2 +- .../README.md | 2 +- .../PresentCodeRedemptionSheet.cs | 2 +- .../README.md | 4 +- .../CanMakePayments.cs | 2 +- .../README.md | 12 +- .../README.md | 1 - .../UpgradeDowngradeSubscription.cs | 2 +- .../README.md | 1 - .../SubscriptionGroup.cs | 2 +- .../RestoringTransactions.cs | 2 +- .../README.md | 2 - .../HandlingDeferredPurchases.cs | 2 +- .../README.md | 3 - .../FraudDetection.cs | 2 +- Third Party Notices.md | 1 - ValidationExceptions.json | 10 + ValidationExceptions.json.meta | 3 + package.json | 17 +- 264 files changed, 8117 insertions(+), 7058 deletions(-) create mode 100644 Documentation~/InitializationOverview.md delete mode 100644 Documentation~/UnityIAPInitializeUnityGamingServices.md.meta create mode 100644 Documentation~/images/AutoInitializeUGS.png create mode 100644 Documentation~/images/UGSInitializationFlowDiagram.png create mode 100644 Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml create mode 100644 Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml.meta create mode 100644 Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs create mode 100644 Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs.meta create mode 100644 Runtime/Purchasing/CoreServices.meta create mode 100644 Runtime/Purchasing/CoreServices/Interfaces.meta create mode 100644 Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs create mode 100644 Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs.meta create mode 100644 Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs create mode 100644 Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs.meta create mode 100644 Runtime/Purchasing/Utilites/ILoggerExtensions.cs create mode 100644 Runtime/Purchasing/Utilites/ILoggerExtensions.cs.meta create mode 100644 Runtime/Stores/Android/GooglePlay/package.json create mode 100644 Runtime/Stores/Android/GooglePlay/package.json.meta create mode 100644 Samples~/06 InitializeGamingServices/.sample.json create mode 100644 ValidationExceptions.json create mode 100644 ValidationExceptions.json.meta diff --git a/CHANGELOG-ASSETSTORE.md b/CHANGELOG-ASSETSTORE.md index f7e1983..d68d83f 100644 --- a/CHANGELOG-ASSETSTORE.md +++ b/CHANGELOG-ASSETSTORE.md @@ -29,7 +29,7 @@ - GooglePlay - Missing ProcessPurchase callback at app start when the transaction is (1) purchased, (2) processed by the app with a ProcessPurchaseResult.Pending result, (3) the app is terminated, and (4) the app is restarted - GooglePlay - NullReferenceException from "FillPurchases" (logged internal API) when returning from background, unpredictably - Apple - Unity IAP 2.2.2's Apple Silicon fix not included in release; continuous integration pipeline fixed -- `StandardPurchasingModule.appStore` returns `AppStore.MacAppStore` for Mac App Store, `AppStore.AppleAppStore` for iOS App Store, and `AppStore.WinRT` for Windows Desktop. (No change to +- `StandardPurchasingModule.appStore` returns `AppStore.MacAppStore` for Mac App Store, `AppStore.AppleAppStore` for iOS App Store, and `AppStore.WinRT` for Windows Desktop. (No change to `AppStore.SamsungApps`, `AppStore.AmazonAppStore`, or `AppStore.GooglePlay`.) ## [2.2.4] - 2020-12-03 @@ -38,7 +38,7 @@ - GooglePlay - `IStoreListener.ProcessPurchase` called more than once for any purchase which is not consumed, i.e. when `ProcessPurchaseResult.Pending` is returned, by fixing a race-condition. ### Changed -- GooglePlay - To receive `ProcessPurchase` calls after foregrounding the app, when a purchase is made outside the app (e.g. in the Play Store app), please upgrade the core package via the Package Manager to `com.unity.purchasing@2.2.1` or higher. +- GooglePlay - To receive `ProcessPurchase` calls after foregrounding the app, when a purchase is made outside the app (e.g. in the Play Store app), please upgrade the core package via the Package Manager to `com.unity.purchasing@2.2.1` or higher. ## [2.2.3] - 2020-12-01 @@ -63,13 +63,13 @@ ## [2.2.1] - 2020-11-13 ### Fixed -- GooglePlay - ProductMetadata.localizedPrice always `0` +- GooglePlay - ProductMetadata.localizedPrice always `0` - GooglePlay - "Main" thread warning seen in IStoreListener.OnInitialized and related callbacks. ### Added - GooglePlay - Subscription metadata is now available in `GoogleProductMetadata` from `ProductMetadata.GetGoogleProductMetadata()` via `IStoreController.products`. - For example, use `GoogleProductMetadata googleMetadata = storeController.product.all[0].metadata.GetGoogleProductMetadata();` now instead of the deprecated, `IGooglePlayStoreExtensions.GetProductJSONDictionary`. - - string originalJson - Note, a single SkuDetails JSON, instead of multiple from `GetProductJSONDictionary` + - string originalJson - Note, a single SkuDetails JSON, instead of multiple from `GetProductJSONDictionary` - string subscriptionPeriod - string freeTrialPeriod - string introductoryPrice @@ -105,14 +105,14 @@ - IAP Catalog - GooglePlay - pricing template when exporting to CSV, now sets autofill pricing to `false` instead of `true` - GooglePlay - Subscription receipts will update, e.g. after an upgrade or downgrade, whenever the player pauses or resumes their app. See this change reflected in the `product.receipt` of `IStoreController.products`. -### Added +### Added - Apple Macos - Support for building IL2CPP on MacOS ## [2.1.1] - 2020-10-23 ### Fixed - Amazon - Fix build failure caused by duplicate classes -- Amazon - Fix ResponseReceiver flaw reported by Amazon APK audit caused by permission attribute location in AndroidManifest.xml +- Amazon - Fix ResponseReceiver flaw reported by Amazon APK audit caused by permission attribute location in AndroidManifest.xml ## [2.1.0] - 2020-10-14 ### Future @@ -122,8 +122,8 @@ - GooglePlay - Live payments using `aggressivelyRecoverLostPurchases = true` - switched to Google's Purchase Token from using Google's Order ID to represent all transaction IDs. Automatically sets `Product.transactionID` to GooglePlay `purchaseToken` when `aggressivelyRecoverLostPurchases` is `true`. Continues to use `orderId`, otherwise. CLARIFICATION: To reinforce the preferred usage of `aggressivelyRecoverLostPurchases`, a de-duplicating backend purchase verification server is recommended to be added to a game's transaction verification pipeline when using this feature. Without such a server the recovered purchases may not be easily or safely de-duplicated by a client. - When upgrading from previous versions of Unity IAP, and if enabled `bool IGooglePlayConfiguration.aggressivelyRecoverLostPurchases`, any purchases your users have made will be processed again by Unity IAP; ProcessPurchase will be called for all these purchases. - - Afterwards, for future purchases, this change reduces future duplicate processing calls; ProcessPurchase should no longer be called after an interrupted purchase for an item already purcahsed by the user. - - Update purchase verification servers to treat orderId and purchaseToken as the same ID. Extract the purchaseToken from the JSON receipt, or fetch the orderId using a purchaseToken and the server API [`purchases.products` Google Play Developer API](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products). + - Afterwards, for future purchases, this change reduces future duplicate processing calls; ProcessPurchase should no longer be called after an interrupted purchase for an item already purcahsed by the user. + - Update purchase verification servers to treat orderId and purchaseToken as the same ID. Extract the purchaseToken from the JSON receipt, or fetch the orderId using a purchaseToken and the server API [`purchases.products` Google Play Developer API](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products). - Override this behavior with `UsePurchaseTokenForTransactionId`, below. - GooglePlay Security - Add GooglePlayReceipt.orderID and obsolete GooglePlayReceipt.transactionID for the local receipt validator, for clarity. - GooglePlay - Reduce the frequency of double-processing when a purchase is canceled, if using `aggressivelyRecoverLostPurchases = true`. Always records purchaseToken in TransactionLog when a transaction is completed. @@ -131,9 +131,9 @@ ### Added - GooglePlay - Ability to override the `Product.transactionID` and use either Google's Purchase Token, or the legacy Order ID when possible, with `UsePurchaseTokenForTransactionId`. - Call `void IGooglePlayConfiguration.UsePurchaseTokenForTransactionId(bool usePurchaseToken)` to disable the default behavior - - a) `false` to use the `orderId`, when possible; this is the legacy and non-unique transaction ID behavior. This must switch to the purchaseToken when the orderId is not available from Google, which is when `aggressivelyRecoverLostPurchases = true`. + - a) `false` to use the `orderId`, when possible; this is the legacy and non-unique transaction ID behavior. This must switch to the purchaseToken when the orderId is not available from Google, which is when `aggressivelyRecoverLostPurchases = true`. - b) `true` to always use the unique purchaseToken in transactionID. NOTE: this is the preferred option, and it will be the only behavior available in a future version of Unity IAP. - - **Background:** The GooglePlay purchaseToken is the unique identifier for all GooglePlay purchases; it is always available for all purchase types and all purchase records. The GooglePlay orderId is not available for all purchase types, notably sandbox and promo code, and also is currently not available for those purchase records which are returned for `aggressivelyRecoverLostPurchases = true` recovered purchases: the Google Play Billing history API used by this feature does not return orderId and therefore cannot be set as the Product.transactionID at that time. Historically, Unity IAP chose to prefer orderId for transactionID in its original implementation. And when the orderId was missing from purchase records (sandbox test purchases, and other no-money purchases), Unity IAP would use the purchaseToken as the transactionID. + - **Background:** The GooglePlay purchaseToken is the unique identifier for all GooglePlay purchases; it is always available for all purchase types and all purchase records. The GooglePlay orderId is not available for all purchase types, notably sandbox and promo code, and also is currently not available for those purchase records which are returned for `aggressivelyRecoverLostPurchases = true` recovered purchases: the Google Play Billing history API used by this feature does not return orderId and therefore cannot be set as the Product.transactionID at that time. Historically, Unity IAP chose to prefer orderId for transactionID in its original implementation. And when the orderId was missing from purchase records (sandbox test purchases, and other no-money purchases), Unity IAP would use the purchaseToken as the transactionID. - **Impact:** Since Unity IAP version 1.23.3 this resulted in non-unique transactionIDs for purchases made which were "aggressively" restored: two distinct ProcessPurchase calls for one purchase could be generated, where Product.transactionID would change between two values (orderId and purchaseToken) and be non-unique. With this version, Unity IAP is starting a transition to only using purchaseToken, and avoids the impact for any new purchases made by a user. ### Fixed @@ -159,7 +159,7 @@ ### Changed - IAP Updater "Updater Settings..." button now reads "More Information..." to be more accurate -- UDP store implementation is still available, but must be installed in a separate module, available in either Asset Store or Package manager. The UDP module will need to be updated manually. (See above) +- UDP store implementation is still available, but must be installed in a separate module, available in either Asset Store or Package manager. The UDP module will need to be updated manually. (See above) - Visibility of INativeStores is now public, mainly to support the new UDP package's needs - Resource files - The Product Catalog, Android Target Billing Mode, and Receipt Obfuscator Tangle files will be moved out of the plugins folder. @@ -189,17 +189,17 @@ ### Added - GooglePlay - Improves the chance of successfully purchasing a Consumable or NonConsumable when the _purchase flow_ is interrupted. Also addresses the dialog, "Your order is still being processed". - Unity IAP will now detect this _purchasing_ failure. It will call the `IStoreListener.OnPurchaseFailed` API, initially. Then it will query Google Play for purchase success during the current app session until network is restored, and it will continue querying in the next app session, after a restart. It will finally call the `IStoreListener.ProcessPurchase` API if it finds a successful, unaccounted purchase. - - Addresses the case where (1) a consumable or nonconsumable purchase flow is started and (2) a network disruption occurs, or the app is sent to the background and the purchasing Activity is canceled, or the app is terminated. + - Addresses the case where (1) a consumable or nonconsumable purchase flow is started and (2) a network disruption occurs, or the app is sent to the background and the purchasing Activity is canceled, or the app is terminated. - GooglePlay - Improves the chance of successfully repurchasing a Consumable whose successful transaction failed however to be _completed_ during the current app session. - Unity IAP will now detect this _consumption_ failure. It will automatically retry completing the purchase until it succeeds. Note that `DuplicateTransaction` may still be reported while the retry is ongoing, until the user's product is repurchasable again. See below for new APIs to monitor the consumption flow. - Addresses the case where (1) a Consumable purchase calls `IStoreListener.ProcessPurchase`, then (2) the transaction is completed by returning `ProcessPurchaseResult.Complete` from `IStoreListener.ProcessPurchase` or by directly calling `IStoreController.ConfirmPendingPurchase` [internally this always records the transaction identifier to the TransactionLog], and finally (3) an interruption (network or exit) aborts the transaction consumption. Only restarting the app or refunding the purchase would reliably resolve this case. -- GooglePlay - Adds an `"isOwned" : ` sub-entry to the `Product.receipt`'s `"Payload"` JSON entry in order to help developers understand this product's current ownership state. +- GooglePlay - Adds an `"isOwned" : ` sub-entry to the `Product.receipt`'s `"Payload"` JSON entry in order to help developers understand this product's current ownership state. - Contains `true` if the product is owned by the user. And please note that `true` may also indicate that Unity IAP is actively retrying consumption. Its boolean value will be `false` if the product is available for repurchase, or if we do not yet know Google Play's current status for this product. To clarify the receipt structure, `"isOwned"` is located in the Google Play-specific escaped-JSON sub-document. Sample `Product.receipt`, abbreviated: `{"Payload":"{\"json\": ..., \"signature\": ..., \"isOwned\":true}}"`. See the Google Play section of the [Unity IAP Receipt receipt documentation](https://docs.unity3d.com/Manual/UnityIAPPurchaseReceipts.html) for more on the receipt JSON structure. -- GooglePlay - Adds `boolean IGooglePlayStoreExtensions.IsOwned(Product)` API to conveniently extract the new ownership state, above, from the Google Play JSON receipt. - - Returns `true` if the product is still owned by the user. Returns `false` if the product is available for repurchase. Example: +- GooglePlay - Adds `boolean IGooglePlayStoreExtensions.IsOwned(Product)` API to conveniently extract the new ownership state, above, from the Google Play JSON receipt. + - Returns `true` if the product is still owned by the user. Returns `false` if the product is available for repurchase. Example: ```extensionProvider.GetExtension()``` ```.IsOwned(storeController.products.WithID("100.gold.coins"));```. -- GooglePlay - Adds `void IGooglePlayStoreExtensions.SetLogLevel(int level)` API to reduce logging. +- GooglePlay - Adds `void IGooglePlayStoreExtensions.SetLogLevel(int level)` API to reduce logging. - `level` defaults to the legacy value of `0` and configures the Google Play Java store integration to emit debug, info, warning, and error logs. Setting `1` will restrict logging to emit only warnings and errors. Example: `extensionProvider.GetExtension().SetLogLevel(1)`. - GooglePlay - After the purchasing dialog, "You already own this product" from Google Play is shown, the `IStoreListener.OnPurchaseFailed` API is calls with an error of `PurchaseFailureReason.DuplicateTransaction`. - Unity IAP now treats "You already own this product" as a successful purchase, and _also_ calls `IStoreListener.ProcessPurchase`. Note: This amends the related behavior introduced in 1.23.1. @@ -232,7 +232,7 @@ ### Fixed - GooglePlay - SubscriptionInfo.getSubscriptionInfo() KeyNotFoundException when parsing receipts which omit expected fields. - GooglePlay - IStoreListener.OnInitializeFailed / IStoreCallback.OnSetupFailed should return InitializationFailureReason.AppNotKnown error when user changes password off-device - user must login. Previously erroneously generated infinite error 6 codes when fetching purchase history after password change. -- OverflowException when initializing if device locale used the comma (“,”) character as decimal separator. +- OverflowException when initializing if device locale used the comma (“,”) character as decimal separator. ## [1.22.0] - 2019-03-18 ### Added @@ -258,12 +258,12 @@ - Added a callback function that allows developers to check the state of the upgrade/downgrade process of subscriptions on GooglePlay. ### Fixed -- Google Daydream - Correctly Displays IAP Prompt in 3d VR version instead of native 2D. +- Google Daydream - Correctly Displays IAP Prompt in 3d VR version instead of native 2D. - Fixed issue where IAP catalog prevented deletion of Price under Google Configuration. - Amazon Store - Fixed bug where Amazon store could not correctly parse currencies for certain countries. - MacOS - Fixed bug that causes non-consumables to auto-restore on MacOS apps after re-install, instead of requiring the the Restore button to be clicked. - Updated Android Response Code to return correct message whenever an activity is cancelled. -- Fixed Mono CIL linker error causing initialization failure in Unity 5.3 +- Fixed Mono CIL linker error causing initialization failure in Unity 5.3 - Fixed inefficient Apple Receipt Parser that was slowing down when a large number of transactions were parsed on auto-restore. ## [1.20.0] - 2018-06-29 @@ -271,20 +271,20 @@ - API for developers to check SkuDetails for all GooglePlay store products, including those that have not been purchased. - Error Code Support for Amazon. - Support upgrade/downgrade Subscription Tiers for GooglePlayStore. -- Support Subscription status check (valid/invalid) for Amazon Store. +- Support Subscription status check (valid/invalid) for Amazon Store. ### Changed - Location of Product Catalog from Assets/Plugins/UnityPurchasing/Resources folder to Assets/Resources. - Amazon Receipt with enriched product details and receipt details. ### Fixed -- Issue where Unknown products (including non-consumables) were consumed during initialization. +- Issue where Unknown products (including non-consumables) were consumed during initialization. - ArgumentException where currency was set to null string when purchase was made. ## [1.19.0] - 2018-04-17 ### Added - For GooglePlay store, `developerPayload` has been encoded to base64 string and formatted to a JSON string with two other information of the product. When extract `developerPayload` from the product receipt, firstly decode the json string and get the `developerPayload` field base64 string, secondly decode the base64 string to the original `developerPayload`. -- `SubscriptionManager` - This new class allows developer to query the purchased subscription product's information. (available for AppleStore and GooglePlay store) +- `SubscriptionManager` - This new class allows developer to query the purchased subscription product's information. (available for AppleStore and GooglePlay store) - For GooglePlay store, this class can only be used on products purchased using IAP 1.19.0 SDK. Products purchased on previous SDKs do not have the fields in the "developerPayload" that are needed to parse the subscription information. - If the "Payload" json string field in the product's json string receipt has a "skuDetails" filed, then this product can use `SubscriptionManager` to get its subscription information. - Added the `StoreSpecificPurchaseErrorCode` enum. Currently contains values for all Apple and Google Play error codes that are returned directly from the store. @@ -325,7 +325,7 @@ - Version Log - Changed logging of Unity IAP version (e.g. "1.15.0") to be only at runtime and not while in the Editor ### Fixed -- Facebook - Correctly handles situations where the number of available products exceeds the Facebook server response page size +- Facebook - Correctly handles situations where the number of available products exceeds the Facebook server response page size - Updater will no longer prompt for updates when Unity is running in batch mode - Gradle - Include and relocate sample Proguard configuration file to Assets/Plugins/UnityPurchasing/Android/proguard-user.txt.OPTIONAL.txt; was missing from 1.13.2 - Security - Upgrades project to autogenerate UnityChannelTangle class if missing when GooglePlayTangle obfuscated secret receipt validation support class is present. @@ -342,7 +342,7 @@ ## [1.14.0] - 2017-09-18 ### Added - Codeless IAP - Added an `IAPListener` Component to extend Codeless IAP functionality. Normally with Codeless IAP, purchase events are dispatched to an `IAPButton` UI Component that is associated with a particular product. The `IAPListener` does not show any UI. It will receive purchase events that do not correspond to any active `IAPButton`. - - The active `IAPListener` is a fallback—it will receive any successful or failed purchase events (calls to `ProcessPurchase` or `OnPurchaseFailed`) that are _not_ handled by an active Codeless `IAPButton` Component. + - The active `IAPListener` is a fallback—it will receive any successful or failed purchase events (calls to `ProcessPurchase` or `OnPurchaseFailed`) that are _not_ handled by an active Codeless `IAPButton` Component. - When using the `IAPListener`, you should create it early in the lifecycle of your app, and not destroy it. By default, it will set its `GameObject` to not be destroyed when a new scene is loaded, by calling `DontDestroyOnLoad`. This behavior can be changed by setting the `dontDestroyOnLoad` field in the Inspector. - If you use an `IAPListener`, it should be ready to handle purchase events at any time, for any product. Promo codes, interrupted purchases, and slow store behavior are only a few of the reasons why you might receive a purchase event when you are not showing a corresponding `IAPButton` to handle the event. - Example use: If a purchase is completed successfully with the platform's app store but the user quits the app before the purchase is processed by Unity, Unity IAP will call `ProcessPurchase` the next time it is initialized—typically the next time the app is run. If your app creates an `IAPListener`, the `IAPListener` will be available to receive this `ProcessPurchase` callback, even if you are not yet ready to create and show an `IAPButton` in your UI. @@ -386,21 +386,21 @@ ```csharp public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - // Set the order of the promoted items - var appleExtensions = extensions.GetExtension(); - appleExtensions.SetStorePromotionOrder(new List{ - controller.products.WithID("sword"), - controller.products.WithID("subscription") - }); + // Set the order of the promoted items + var appleExtensions = extensions.GetExtension(); + appleExtensions.SetStorePromotionOrder(new List{ + controller.products.WithID("sword"), + controller.products.WithID("subscription") + }); } ``` ```csharp public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - // Set the visibility of promoted items - var appleExtensions = extensions.GetExtension(); - appleExtensions.SetStorePromotionVisibility(controller.products.WithID("subscription"), AppleStorePromotionVisibility.Hide); - appleExtensions.SetStorePromotionVisibility(controller.products.WithID("100.gold.coins"), AppleStorePromotionVisibility.Default); + // Set the visibility of promoted items + var appleExtensions = extensions.GetExtension(); + appleExtensions.SetStorePromotionVisibility(controller.products.WithID("subscription"), AppleStorePromotionVisibility.Hide); + appleExtensions.SetStorePromotionVisibility(controller.products.WithID("100.gold.coins"), AppleStorePromotionVisibility.Default); } ``` @@ -506,7 +506,7 @@ module.useFakeStoreAlways = true; ### Fixed - Editor - checkmarks refresh for Targeted Android Store after Editor Play/Stop - Editor - hides spurious Component MenuItems -- Linux Editor - BillingMode.json path case-sensitivity +- Linux Editor - BillingMode.json path case-sensitivity - IAP Catalog - clearer text for Export button: "App Store Export" ## [1.9.2] - 2016-11-29 @@ -624,10 +624,10 @@ using UnityEditor; // A sample Editor script. public class MyEditorScript { - void AnEditorMethod() { - // Set the store to Google Play. - UnityPurchasingEditor.TargetAndroidStore(AndroidStore.GooglePlay); - } + void AnEditorMethod() { + // Set the store to Google Play. + UnityPurchasingEditor.TargetAndroidStore(AndroidStore.GooglePlay); + } } ``` @@ -719,4 +719,4 @@ string receipt = builder.Configure ().appReceipt; - Google Play - Apple App Store - Mac App Store -- Windows Store (Universal) \ No newline at end of file +- Windows Store (Universal) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f307b..3b56214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [4.2.1] - 2022-06-14 +### Fixed +- Downgrade `com.unity.services.core` from 1.4.1 to 1.3.1 due to a new bug found in 1.4.1 + +## [4.2.0] - 2022-06-13 + +### Added +- Feature to automatically initialize **Unity Gaming Services** through the catalog UI. Please see the [documentation](https://docs.unity3d.com/Packages/com.unity.purchasing@4.2/manual/UnityIAPInitializeUnityGamingServices.html) for more details. + +### Changed +- The In-App Purchasing package now requires **Unity Gaming Services** to have been initialized before it can be used. +For the time being **IAP** will continue working as usual, but will log a warning if **Unity Gaming Services** has not been initialized. +In future releases of this package, initializing **Unity Gaming Services** will be mandatory. Please see the [documentation](https://docs.unity3d.com/Packages/com.unity.purchasing@4.2/manual/UnityIAPInitializeUnityGamingServices.html) for more details. + ## [4.2.0-pre.2] - 2022-04-28 ### Added @@ -11,6 +25,10 @@ ## [4.2.0-pre.1] - 2022-04-07 +### Added +- Support for the [new Unity Analytics](https://unity.com/products/unity-analytics) [transaction event](https://docs.unity.com/analytics/AnalyticsSDKAPI.html#Transaction). +- The package will now send telemetry diagnostic and metric events to help improve the long-term reliability and performance of the package. + ### Changed - The minimum Unity Editor version supported is 2020.3. - The In-App Purchasing service window now links to the [new Unity Dashboard](https://dashboard.unity3d.com/) for Unity Editors 2022 and up. @@ -20,17 +38,22 @@ - GooglePlay - GoogleBilling is allowed to initialize correctly even if the user's Google account is logged out, so long as it is linked. The user will need to log in to their account to continue making purchases. - Fixed a build error `DirectoryNotFoundException` that occurred when the build platform was iOS or tvOS and the build target was another platform. +## [4.1.5] - 2022-05-17 + +### Fixed +- GooglePlay - Fixed a null reference exception introduced in Unity IAP 4.1.4 that could happen when cancelling an in-app purchase. + ## [4.1.4] - 2022-03-30 ### Fixed -- GooglePlay - Fixed issue where if an app is backgrounded while a purchase is being processed, +- GooglePlay - Fixed issue where if an app is backgrounded while a purchase is being processed, an `OnPurchaseFailed` would be called with the purchase failure reason `UserCancelled`, even if the purchase was successful. ## [4.1.3] - 2022-01-11 ### Fixed - Removed deprecated UnityWebRequest calls, updating them to use safer ones. This avoids compiler warnings that may occur. -- Fixed edge case where Apple StoreKit receipt parsing would fail, preventing validation. +- Fixed a serious edge case where Apple StoreKit receipt parsing might fail, preventing validation. A portion of receipts on iOS could be affected and cause Unity IAP to freeze after the purchase completed, but before the SDK can finalize the purchase. The user will have to uninstall and reinstall your app in order to recover from this. Your customer service will have to refund the user's purchase or apply the purchase in some other way outside of Unity IAP. This bug was accidentally introduced in Unity IAP 4.1.0. To avoid encountering this problem with your app, we suggest you update to this version. ## [4.1.2] - 2021-11-15 @@ -59,7 +82,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc - Fixed warning, missing await for async call in ExponentialRetryPolicy.cs ### Removed -- Removed the original and complex Unity IAP sample known as "Example", or "IAP Demo". Please use the recently added [samples](https://docs.unity3d.com/Packages/com.unity.purchasing@4.0/manual/Overview.html#learn-more) for a granular introduction to In-App Purchasing features. +- Removed the original and complex Unity IAP sample known as "Example", or "IAP Demo". Please use the recently added [samples](https://docs.unity3d.com/Packages/com.unity.purchasing@4.0/manual/Overview.html#learn-more) for a granular introduction to In-App Purchasing features. ## [4.0.3] - 2021-08-18 ### Added @@ -90,7 +113,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc ## [4.0.0] - 2021-07-19 ### Added -- Codeless Listener method to access the store configuration after initialization. +- Codeless Listener method to access the store configuration after initialization. - `CodelessIAPStoreListener.Instance.GetStoreConfiguration` - Several samples to the [Package Manager Details view](https://docs.unity3d.com/Manual/upm-ui-details.html) for com.unity.purchasing: - Fetching additional products @@ -113,13 +136,13 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc - Reorganized and renamed APIs: - `CodelessIAPStoreListener.Instance.ExtensionProvider.GetExtension` to `CodelessIAPStoreListener.Instance.GetStoreExtensions` to match the new `GetStoreConfiguration` API, above - `IGooglePlayStoreExtensions.NotifyDeferredProrationUpgradeDowngradeSubscription` to `IGooglePlayConfiguration.NotifyDeferredProrationUpgradeDowngradeSubscription` - - `IGooglePlayStoreExtensions.NotifyDeferredPurchase` to `IGooglePlayConfiguration.NotifyDeferredPurchase` - - `IGooglePlayStoreExtensions.SetDeferredProrationUpgradeDowngradeSubscriptionListener` to `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` + - `IGooglePlayStoreExtensions.NotifyDeferredPurchase` to `IGooglePlayConfiguration.NotifyDeferredPurchase` + - `IGooglePlayStoreExtensions.SetDeferredProrationUpgradeDowngradeSubscriptionListener` to `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` - `IGooglePlayStoreExtensions.SetDeferredPurchaseListener` to `IGooglePlayConfiguration.SetDeferredPurchaseListener` - - `IGooglePlayStoreExtensions.SetObfuscatedAccountId` to `IGooglePlayConfiguration.SetObfuscatedAccountId` + - `IGooglePlayStoreExtensions.SetObfuscatedAccountId` to `IGooglePlayConfiguration.SetObfuscatedAccountId` - `IGooglePlayStoreExtensions.SetObfuscatedProfileId` to `IGooglePlayConfiguration.SetObfuscatedProfileId` - Apple - Change the order of execution of the post-process build script, which adds the `StoreKitFramework` such that other post-process build scripts can run after it. -- Changed the __Target Android__ Menu app store selection feature to display a window under `Window > Unity IAP > Switch Store...`. To set the app store for the next build, first use __Build Settings__ to activate the Android build target. +- Changed the __Target Android__ Menu app store selection feature to display a window under `Window > Unity IAP > Switch Store...`. To set the app store for the next build, first use __Build Settings__ to activate the Android build target. - For the future Unity 2022 - Moved Unity IAP menu items from `Window > Unity IAP > ...` to `Services > In-App Purchasing > ...` - Updated and added new functionnality to the `Services > In-App Purchasing` window in the `Project Settings`. The `Current Targeted Store` selector and `Receipt Obfuscator` settings are now accessible from this window. @@ -153,7 +176,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc - `UnityPurchasingEditor.TargetAndroidStore(AndroidStore)`. Use `TargetAndroidStore(AppStore)` instead. - `WinRT` class. Use `WindowsStore` instead. - `WindowsPhone8` class. Use `WindowsStore` instead. - + ## [3.2.3] - 2021-07-08 ### Fixed - GooglePlay - Fix `DuplicateTransaction` errors seen during purchase, after a purchase had previously been Acknowledged with Google. @@ -170,7 +193,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc ## [3.2.1] - 2021-05-18 ### Changed -- Manual and API documentation updated. +- Manual and API documentation updated. ## [3.2.0] - 2021-05-17 ### Added @@ -218,7 +241,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc ### Added - GooglePlay - populate `Product.receipt` for `Action` parameter returned by `IGooglePlayStoreExtensions.SetDeferredPurchaseListener` callback -### Changed +### Changed - WinRT - This feature is now shipped as C# code under assembly definitions instead of .dll files. - Security - This feature is now shipped as C# code under assembly definitions instead of .dll files. - Receipt Validation Obfuscator - The Tangle File Obfuscate function is now Editor-only and no longer part of the Runtime Security module. @@ -234,7 +257,7 @@ an `OnPurchaseFailed` would be called with the purchase failure reason `UserCanc ## [3.0.0-pre.5] - 2021-01-12 ### Added -- Apple - Support for [auto-renewable subscription Offer Codes](https://developer.apple.com/documentation/storekit/in-app_purchase/subscriptions_and_offers/implementing_offer_codes_in_your_app) on iOS and iPadOS 14 and later via `IAppleExtensions.PresentOfferRedemptionSheet()`. E.g. +- Apple - Support for [auto-renewable subscription Offer Codes](https://developer.apple.com/documentation/storekit/in-app_purchase/subscriptions_and_offers/implementing_offer_codes_in_your_app) on iOS and iPadOS 14 and later via `IAppleExtensions.PresentOfferRedemptionSheet()`. E.g. ```csharp public void ShowSubscriptionOfferRedemption(IExtensionProvider extensions) @@ -245,7 +268,7 @@ public void ShowSubscriptionOfferRedemption(IExtensionProvider extensions) ``` ### Fixed - - Security and WinRT stub dlls and references to Analytics no longer break builds unsupported platforms like PS4, XboxOne, Switch and Lumin. These platforms are still unsupported but will no longer raise errors on build. + - Security and WinRT stub dlls and references to Analytics no longer break builds unsupported platforms like PS4, XboxOne, Switch and Lumin. These platforms are still unsupported but will no longer raise errors on build. ### Removed - Support for Facebook in-app purchasing is no longer provided. All classes and implementations have been removed. @@ -292,7 +315,7 @@ Fix migration tooling's obfuscator file destination path to target Scripts inste - Added editor and playmode testing. ## [2.0.3] - 2018-06-14 -- Fixed issue related to 2.0.2 that caused new projects to not compile in the editor. +- Fixed issue related to 2.0.2 that caused new projects to not compile in the editor. - Engine dll is enabled for editor by default. - Removed meta data that disabled engine dll for windows store. diff --git a/Documentation~/AmazonTesting.md b/Documentation~/AmazonTesting.md index 28e7495..cf98278 100644 --- a/Documentation~/AmazonTesting.md +++ b/Documentation~/AmazonTesting.md @@ -9,18 +9,18 @@ var builder = ConfigurationBuilder.Instance( StandardPurchasingModule.Instance()); // Define your products. builder.AddProduct("someConsumable", ProductType.Consumable); -// Write a product description to the SD card +// Write a product description to the SD card // in the appropriate location. builder.Configure() - .WriteSandboxJSON(builder.products); + .WriteSandboxJSON(builder.products); ```` When using this method to write product descriptions to the SD card, declare the Android permission to write to external storage in the test app’s manifest: ```` - + ```` -Remove this extra permission before publishing, if appropriate. +Remove this extra permission before publishing, if appropriate. -Amazon Sandbox is now set up for local testing. For more information, please see Amazon's [App Tester documentation](https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/installing-and-configuring-app-tester). \ No newline at end of file +Amazon Sandbox is now set up for local testing. For more information, please see Amazon's [App Tester documentation](https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/installing-and-configuring-app-tester). diff --git a/Documentation~/AppleReceipt.md b/Documentation~/AppleReceipt.md index 9a40604..418ecd6 100644 --- a/Documentation~/AppleReceipt.md +++ b/Documentation~/AppleReceipt.md @@ -15,4 +15,4 @@ Payload varies depending upon the device's iOS version. Mac App Store ------------- -Payload is a base 64 encoded [App Receipt](https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#/apple_ref/doc/uid/TP40010573-CH106-SW1). \ No newline at end of file +Payload is a base 64 encoded [App Receipt](https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#/apple_ref/doc/uid/TP40010573-CH106-SW1). diff --git a/Documentation~/AppleTesting.md b/Documentation~/AppleTesting.md index ab6ff61..34249d3 100644 --- a/Documentation~/AppleTesting.md +++ b/Documentation~/AppleTesting.md @@ -31,5 +31,3 @@ To sign the bundle, you may first need to remove the Contents.meta file if it ex In order to install the package correctly you must delete the unpackaged .app file before running the newly created package. You must then launch your App from the Applications folder. The first time you do so, you will be prompted to enter your iTunes account details, for which you should enter your App Store Connect test user account login. You will then be able to make test purchases against the sandbox environment. - - diff --git a/Documentation~/BackendReceiptValidation.md b/Documentation~/BackendReceiptValidation.md index ebe798f..774eeb0 100644 --- a/Documentation~/BackendReceiptValidation.md +++ b/Documentation~/BackendReceiptValidation.md @@ -2,4 +2,4 @@ Backend receipt validation helps you prevent users from accessing content they have not purchased. -For server-side content, where content is downloaded once purchased, the validation should take place on the server before the content is released. Unity does not offer support for server-side validation; however, third-party solutions are available, such as Nobuyori Takahashi’s [IAP project](https://github.com/voltrue2/in-app-purchase). \ No newline at end of file +For server-side content, where content is downloaded once purchased, the validation should take place on the server before the content is released. Unity does not offer support for server-side validation; however, third-party solutions are available, such as Nobuyori Takahashi’s [IAP project](https://github.com/voltrue2/in-app-purchase). diff --git a/Documentation~/DefiningProductsCoded.md b/Documentation~/DefiningProductsCoded.md index 7f7a892..437de61 100644 --- a/Documentation~/DefiningProductsCoded.md +++ b/Documentation~/DefiningProductsCoded.md @@ -1,6 +1,6 @@ # Defining Products in scripts -You can declare your Product list programmatically using the [Purchasing Configuration Builder](xref:UnityEngine.Purchasing.ConfigurationBuilder). +You can declare your Product list programmatically using the [Purchasing Configuration Builder](xref:UnityEngine.Purchasing.ConfigurationBuilder). You must provide a unique cross-store __Product ID__ and __Product Type__ for each Product: ```` diff --git a/Documentation~/DefiningProductsOverview.md b/Documentation~/DefiningProductsOverview.md index 6a26482..bb608f0 100644 --- a/Documentation~/DefiningProductsOverview.md +++ b/Documentation~/DefiningProductsOverview.md @@ -1,7 +1,7 @@ # Defining products ## Product ID -Enter a cross-platform unique identifier to serve as the Product’s default ID when communicating with an app store. +Enter a cross-platform unique identifier to serve as the Product’s default ID when communicating with an app store. **Important**: The ID may only contain lowercase letters, numbers, underscores, or periods. @@ -34,12 +34,11 @@ Use this section to add local, fixed definitions for the content you pay out to | __Payout Type__ | Enum | Defines the category of content the purchaser receives. There are four possible Types. | * Currency
* Item
* Resource
* Other| | __Payout Subtype__ | String | Provides a level of granularity to the content category. |* “Gold” and “Silver” subtypes of a __Currency__ type
* “Potion” and “Boost” subtypes of an __Item__ type | | __Quantity__ | Int | Specifies the number of items, currency, and so on, that the purchaser receives in the payout. | * 1
* >25
* 100| -| __Data__ | | Use this field any way you like as a property to reference in code. | * Flag for a UI element
* Item rarity | +| __Data__ | | Use this field any way you like as a property to reference in code. | * Flag for a UI element
* Item rarity | -**Note**: You can add multiple Payouts to a single Product. +**Note**: You can add multiple Payouts to a single Product. For more information on the PayoutDefinition class, see the [Scripting Reference](xref:UnityEngine.Purchasing.PayoutDefinition). You can always add Payout information to a Product in a script using this class. For example: ### Store ID Overrides By default, Unity IAP assumes that your Product has the same identifier (specified in the **ID** field, above) across all app stores. Unity recommends doing this where possible. However, there are occasions when this is not possible, such as when publishing to both iOS and Mac stores, which prohibit developers from using the same product ID across both. - diff --git a/Documentation~/GettingStarted.md b/Documentation~/GettingStarted.md index cff0519..58a889b 100644 --- a/Documentation~/GettingStarted.md +++ b/Documentation~/GettingStarted.md @@ -15,4 +15,4 @@ 7. Click the toggle next to **In-App Purchasing Settings** to **ON**. -This will automatically install the IAP package from the Package Manager, providing you with new features and menu items to help you manage IAP. \ No newline at end of file +This will automatically install the IAP package from the Package Manager, providing you with new features and menu items to help you manage IAP. diff --git a/Documentation~/GooglePublicKey.md b/Documentation~/GooglePublicKey.md index 233c9ec..5784546 100644 --- a/Documentation~/GooglePublicKey.md +++ b/Documentation~/GooglePublicKey.md @@ -14,4 +14,4 @@ It is possible to set the Google Public Key in two different places either in th 2. Open the left menu and select `Settings` then `Project Settings` under `Project` ![GooglePublicKeyDashboardSetting](images/IAPGooglePublicKeyDashboardSetting.png) and select the project 3. In the section `In-app purchase (IAP) settings` edit the field `Google License Key` -![GooglePublicKeyDashboard](images/IAPGooglePublicKeyDashboard.png) \ No newline at end of file +![GooglePublicKeyDashboard](images/IAPGooglePublicKeyDashboard.png) diff --git a/Documentation~/GoogleReceipt.md b/Documentation~/GoogleReceipt.md index fa7a680..abca7b7 100644 --- a/Documentation~/GoogleReceipt.md +++ b/Documentation~/GoogleReceipt.md @@ -10,4 +10,4 @@ Payload is a JSON hash with the following keys and values: |Key|Value| |:---|:---| |__json__|A JSON encoded string provided by Google; [`INAPP_PURCHASE_DATA`](http://developer.android.com/google/play/billing/billing_reference.html)| -|__signature__|A signature for the json parameter, as provided by Google; [`INAPP_DATA_SIGNATURE`](http://developer.android.com/google/play/billing/billing_reference.html)| \ No newline at end of file +|__signature__|A signature for the json parameter, as provided by Google; [`INAPP_DATA_SIGNATURE`](http://developer.android.com/google/play/billing/billing_reference.html)| diff --git a/Documentation~/HowToTest.md b/Documentation~/HowToTest.md index 8472aad..e169ecd 100644 --- a/Documentation~/HowToTest.md +++ b/Documentation~/HowToTest.md @@ -16,4 +16,4 @@ This option display no dialog. This option will show a simple dialog is shown when Purchasing. ### 3. DeveloperUser -This option will show a dialog giving options for failure reason code selection when Initializing/Retrieving Products and when Purchasing. \ No newline at end of file +This option will show a dialog giving options for failure reason code selection when Initializing/Retrieving Products and when Purchasing. diff --git a/Documentation~/IAPListener.md b/Documentation~/IAPListener.md index 74550b4..e7095d4 100644 --- a/Documentation~/IAPListener.md +++ b/Documentation~/IAPListener.md @@ -28,7 +28,7 @@ When your catalog contains at least one Product, you can define __IAP Button__ b public void GrantCredits (int credits){ userCredits = userCredits + credits; Debug.Log(“You received “ + credits “ Credits!”); -} +} ``` Run your game to test the __IAP Button__. diff --git a/Documentation~/InitializationOverview.md b/Documentation~/InitializationOverview.md new file mode 100644 index 0000000..a362c42 --- /dev/null +++ b/Documentation~/InitializationOverview.md @@ -0,0 +1,6 @@ +# Initialization + +In order to use the **Unity In-App Purchasing** package, **Unity Gaming Services** needs to be initialized first followed by the initialization of **Unity In-App Purchasing**. + +The following diagram visually describes steps to initialize Unity In-App Purchasing. +![Initialization flow diagram](images/UGSInitializationFlowDiagram.png) diff --git a/Documentation~/Overview.md b/Documentation~/Overview.md index 0dcb19a..80f2ba9 100644 --- a/Documentation~/Overview.md +++ b/Documentation~/Overview.md @@ -58,4 +58,4 @@ More information can be found in the `Stores` section of this manual #### Unity Learn IAP classes -[Refer to the Unity Learn IAP classes](https://learn.unity.com/tutorial/unity-iap) for more guidance. +[Refer to the Unity Learn IAP classes](https://learn.unity.com/tutorial/unity-iap) for more guidance. diff --git a/Documentation~/StoresSupported.md b/Documentation~/StoresSupported.md index c8da56a..a7cb1c2 100644 --- a/Documentation~/StoresSupported.md +++ b/Documentation~/StoresSupported.md @@ -10,4 +10,4 @@ The following is the full list of stores supported by the In-App Purchasing pack |Samsung|Android|Removed use [UDP](https://unity.com/products/unity-distribution-portal) instead| [UDP](https://unity.com/products/unity-distribution-portal)| |Unity Distribution Portal|Android|2.0.0 and higher|[UDP](https://unity.com/products/unity-distribution-portal)| |App Store|MacOS / iOS / tvOS|Store Kit v1|[Apple Store Kit](https://developer.apple.com/documentation/storekit)| -|Microsoft Store|Windows||[Microsoft SDK](https://docs.microsoft.com/en-us/windows/uwp/monetize/in-app-purchases-and-trials)| \ No newline at end of file +|Microsoft Store|Windows||[Microsoft SDK](https://docs.microsoft.com/en-us/windows/uwp/monetize/in-app-purchases-and-trials)| diff --git a/Documentation~/TableOfContents.md b/Documentation~/TableOfContents.md index 7f7893e..ac9d08d 100644 --- a/Documentation~/TableOfContents.md +++ b/Documentation~/TableOfContents.md @@ -8,8 +8,10 @@ * [Overview](DefiningProductsOverview.md) * [Coded](DefiningProductsCoded.md) * [IAP Catalog](UnityIAPDefiningProducts.md) - * [Initialize IAP](UnityIAPInitialization.md) - * [Initialize Unity Gaming Services](UnityIAPInitializeUnityGamingServices.md) + * Initialization + * [Overview](InitializationOverview.md) + * [Initialize Unity Gaming Services](UnityIAPInitializeUnityGamingServices.md) + * [Initialize IAP](UnityIAPInitialization.md) * [Fetching Additional Products](UnityIAPFetchingProductsIncrementally.md) * Creating a Purchasing Button * [Browsing Product Metadata](UnityIAPBrowsingMetadata.md) diff --git a/Documentation~/UnityIAPAmazonConfiguration.md b/Documentation~/UnityIAPAmazonConfiguration.md index 1323e5f..7f34059 100644 --- a/Documentation~/UnityIAPAmazonConfiguration.md +++ b/Documentation~/UnityIAPAmazonConfiguration.md @@ -2,7 +2,7 @@ ## Introduction -This guide describes the process of setting up the Amazon Appstore for use with the Unity in-app purchasing (IAP) system. This includes establishing the digital records and relationships that are required to interact with the Unity IAP API, setting up an Amazon developer account, and testing and publishing a Unity IAP application. +This guide describes the process of setting up the Amazon Appstore for use with the Unity in-app purchasing (IAP) system. This includes establishing the digital records and relationships that are required to interact with the Unity IAP API, setting up an Amazon developer account, and testing and publishing a Unity IAP application. As with other platforms, the Amazon store allows for the purchase of virtual goods and managed products. These digital products are identified using a string identifier and an additional type to define durability, with choices including subscription (capable of being subscribed to), consumable (capable of being rebought), and non-consumable (capable of being bought once). @@ -19,15 +19,15 @@ As with other platforms, the Amazon store allows for the purchase of virtual goo 2. For FireOS devices, the Amazon Appstore should come pre-installed.

**Note**: Though you may freely target FireOS devices, FireOS is not a Unity-supported platform.

3. Once you have installed the Amazon Appstore, install the [Amazon App Tester](http://www.amazon.com/Amazon-App-Tester/dp/B00BN3YZM2/). - ![](images/AmazonConfiguration-AmazonAppTester.png) + ![](images/AmazonConfiguration-AmazonAppTester.png) 1. Set up the Android SDK 1. To install and watch the Android debug log, ensure you have the [Android SDK](https://developer.android.com/studio/install.html) installed. Download the relevant command line tools package from the Android SDK install page and extract them to your computer. 1. Confirm that the SDK recognizes the attached Android device through the command-line adb tool. For example: - + ```` |[11:07:01] user@laptop:/Applications | $ adb devices List of devices attached -00DA0807526300W5 device +00DA0807526300W5 device ```` ### Unity app setup @@ -48,6 +48,3 @@ It's not necessary to download Amazon's native IAP plug-in when preparing to use 1. Set up your catalog. Using the product descriptions you prepared earlier, add the items to the Amazon catalog using the Amazon Developer Portal. Navigate to your app's page, and find the __In-App Items section__. Use the __Add a Consumable__, __Add an Entitlement__, or __Add a Subscription__ buttons to set up your catalog. ![](images/AmazonConfiguration-SetUpCatalog.png) - - - diff --git a/Documentation~/UnityIAPAmazonExtendedFunctionality.md b/Documentation~/UnityIAPAmazonExtendedFunctionality.md index 989eab9..005026d 100644 --- a/Documentation~/UnityIAPAmazonExtendedFunctionality.md +++ b/Documentation~/UnityIAPAmazonExtendedFunctionality.md @@ -10,9 +10,8 @@ To fetch the current Amazon User ID for other Amazon services, use the `IAmazonE public void OnInitialized (IStoreController controller, IExtensionProvider extensions) { - string amazonUserId = + string amazonUserId = extensions.GetExtension().amazonUserId; // ... } ```` - diff --git a/Documentation~/UnityIAPAppleConfiguration.md b/Documentation~/UnityIAPAppleConfiguration.md index 3913699..463def6 100644 --- a/Documentation~/UnityIAPAppleConfiguration.md +++ b/Documentation~/UnityIAPAppleConfiguration.md @@ -4,7 +4,7 @@ This guide describes the process of establishing the digital records and relationships necessary for a Unity game to interact with an In-App Purchase Store. -In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows purchase of Products, representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought only once) are the most common. +In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows purchase of Products, representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought only once) are the most common. ## Apple App Store @@ -18,40 +18,40 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p ### Register the Application -1. In the [Apple Developer Center](https://developer.apple.com/account), navigate to the appropriate Identifiers section. +1. In the [Apple Developer Center](https://developer.apple.com/account), navigate to the appropriate Identifiers section. + +2. Add a new App ID to create a fundamental application entity with Apple. -2. Add a new App ID to create a fundamental application entity with Apple. - **NOTE:** Use an Explicit App ID. Wildcard App IDs (com.example.*) cannot be used for applications that use In-App Purchases. **NOTE:** The App ID is available to use in App Store Connect after you create it in the Developer Center. - + ![](images/IAPAppleImage1.png) -3. Navigate to [App Store Connect](https://itunesconnect.apple.com) and create an App, to establish a Store relationship with your game. - - ![](images/IAPAppleImage2.png) +3. Navigate to [App Store Connect](https://itunesconnect.apple.com) and create an App, to establish a Store relationship with your game. + + ![](images/IAPAppleImage2.png) -4. Use the newly created App ID for the app's Bundle ID. +4. Use the newly created App ID for the app's Bundle ID. ![](images/IAPAppleImage3.png) ### Add In-App Purchases -1. Choose __Features__ and add a new In-App Purchase with the plus ("+") button. +1. Choose __Features__ and add a new In-App Purchase with the plus ("+") button. ![](images/IAPAppleImage4.png) -2. Choose a [Product Type](DefiningProductsOverview.md#Product-Type). +2. Choose a [Product Type](DefiningProductsOverview.md#Product-Type). ![](images/IAPAppleImage5.png) 3. Specify the Product Identifier, and complete other fields as requested. - **NOTE:** The "Product ID" here is the same identifier used in the game source code, added to the [Unity IAP ConfigurationBuilder](xref:UnityEngine.Purchasing.ConfigurationBuilder) instance via __AddProduct()__ or __AddProducts()__. - - **NOTE:** When targeting multiple Apple device groups (for example, shipping on both iOS and Mac) Apple requires usage of different, unique product identifiers for each distinct device group. Use [Unity IAP's Purchasing.IDs](xref:UnityEngine.Purchasing.IDs) class and define a one-to-many mapping Product IDs to the store-specific identifiers, and pass that mapping in when initializing IAP. + **NOTE:** The "Product ID" here is the same identifier used in the game source code, added to the [Unity IAP ConfigurationBuilder](xref:UnityEngine.Purchasing.ConfigurationBuilder) instance via __AddProduct()__ or __AddProducts()__. + + **NOTE:** When targeting multiple Apple device groups (for example, shipping on both iOS and Mac) Apple requires usage of different, unique product identifiers for each distinct device group. Use [Unity IAP's Purchasing.IDs](xref:UnityEngine.Purchasing.IDs) class and define a one-to-many mapping Product IDs to the store-specific identifiers, and pass that mapping in when initializing IAP. ![](images/IAPAppleImage6.png) @@ -61,13 +61,13 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p ### Test IAP -1. Create __Sandbox Testers__ using App Store Connect for use on your test device's iTunes Account. To do this, navigate to __App Store Connect > Users and Roles__, and choose the plus ("+") button. You must review [Apple's Sandbox Tester documentation](https://help.apple.com/app-store-connect/#/dev8b997bee1) as there are several additional important usage notes, and you must use a real email address to create Testers. - - **TIP:** (*) To simplify managing the email address, use an email service capable of sub-addressing (emailaccount+subaddress@example.com) such as Gmail, iCloud, and Outlook.com. This allows one email account to receive email for multiple sub-addresses. +1. Create __Sandbox Testers__ using App Store Connect for use on your test device's iTunes Account. To do this, navigate to __App Store Connect > Users and Roles__, and choose the plus ("+") button. You must review [Apple's Sandbox Tester documentation](https://help.apple.com/app-store-connect/#/dev8b997bee1) as there are several additional important usage notes, and you must use a real email address to create Testers. + + **TIP:** (*) To simplify managing the email address, use an email service capable of sub-addressing (emailaccount+subaddress@example.com) such as Gmail, iCloud, and Outlook.com. This allows one email account to receive email for multiple sub-addresses. ![](images/IAPAppleImage8.png) -2. Walk through the user creation wizard. +2. Walk through the user creation wizard. ![](images/IAPAppleImage9.png) @@ -85,12 +85,12 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p ![](images/IAPAppleImage11.png) -2. Build and run the game on your iOS device. `UnityPurchasing.Initialize()` succeeds if everything has been correctly configured. See [Unity Purchasing Initialization](xref:UnityEngine.Purchasing.UnityPurchasing) +2. Build and run the game on your iOS device. `UnityPurchasing.Initialize()` succeeds if everything has been correctly configured. See [Unity Purchasing Initialization](xref:UnityEngine.Purchasing.UnityPurchasing) -3. Test the IAP by making a purchase in the game on the device. A modified purchase dialog displays, explaining that this purchase is being performed in the Sandbox Environment. Use the Sandbox User Tester password when prompted for purchase. +3. Test the IAP by making a purchase in the game on the device. A modified purchase dialog displays, explaining that this purchase is being performed in the Sandbox Environment. Use the Sandbox User Tester password when prompted for purchase. WARNING: If the indicator is not present, then an account is charged real money for the product. - + ![](images/IAPAppleImage12.png) #### For Mac @@ -102,11 +102,11 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p 3. Sign, package, and install your application. Run the following commands from an OSX terminal, filling in "your.app" and "your.pkg" appropriately. **TIP:** To sign the bundle, you may first need to remove the Contents.meta file if it exists: `your.app/Contents/Plugins/unitypurchasing.bundle/Contents.meta` - + 1. `codesign -f --deep -s "3rd Party Mac Developer Application: " your.app/Contents/Plugins/unitypurchasing.bundle` - + 1. `codesign -f --deep -s "3rd Party Mac Developer Application: " your.app` - + 1. `productbuild --component your.app /Applications --sign "3rd Party Mac Developer Installer: " your.pkg` 4. To install the package correctly, delete the unpackaged .app file before running the newly created package and installing it. @@ -114,6 +114,4 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p 5. Launch the app from the _Applications_ folder. The first time you do so, you are prompted to enter your iTunes account details, for which you can then make test purchases against the sandbox environment. -See pages on [iOS and Mac Extended Functionality](UnityIAPiOSMAS.md) and [Delivering applications to the Apple Mac Store](https://docs.unity3d.com/Manual/HOWTO-PortToAppleMacStore.html) for additional details on Apple App Store testing and signing. - - +See pages on [iOS and Mac Extended Functionality](UnityIAPiOSMAS.md) and [Delivering applications to the Apple Mac Store](https://docs.unity3d.com/Manual/HOWTO-PortToAppleMacStore.html) for additional details on Apple App Store testing and signing. diff --git a/Documentation~/UnityIAPBrowsingMetadata.md b/Documentation~/UnityIAPBrowsingMetadata.md index 33bb225..23e50c8 100644 --- a/Documentation~/UnityIAPBrowsingMetadata.md +++ b/Documentation~/UnityIAPBrowsingMetadata.md @@ -9,4 +9,3 @@ foreach (var product in controller.products.all) { Debug.Log (product.metadata.localizedPriceString); } ```` - diff --git a/Documentation~/UnityIAPDefiningProducts.md b/Documentation~/UnityIAPDefiningProducts.md index c7a6399..e70d23c 100644 --- a/Documentation~/UnityIAPDefiningProducts.md +++ b/Documentation~/UnityIAPDefiningProducts.md @@ -1,9 +1,9 @@ # IAP Catalog To open the __IAP Catalog__ GUI one of two ways: - + * Select __Services > In-App Purchasing > IAP Catalog__. -* Or, with your __IAP Button__ selected, locate its __IAP Button (Script)__ component in the Inspector, then click __IAP Catalog…__. +* Or, with your __IAP Button__ selected, locate its __IAP Button (Script)__ component in the Inspector, then click __IAP Catalog…__. ![Accessing the **IAP Catalog** GUI through an **IAP Button** script component](images/OpenCatalogGUI.png) @@ -16,7 +16,7 @@ Next, use the GUI to define the following attributes for each Product in your ca ![Populating Product information in the **IAP Catalog** GUI](images/IAPCatalogGUI.png) -**Note:** +**Note:** - The __IAP Catalog__ GUI provides additional tools for configuring your Products. Before [exporting a catalog](#Exporting-to-an-app-store) for upload to its respective store, you must populate description and pricing information as well. - The __IAP Catalog__ acts as a Product catalog dictionary, not as an inventory manager. You must still implement the code that handles conveyance of the purchased content. @@ -31,7 +31,7 @@ This sections defines the [descriptions of a product](DefiningProductsOverview.m Add __Translations__ for the __Title__ and __Description__ fields by clicking the plus (__+__) icon and selecting an additional locale. You can add as many translations as you like. ### Payouts -This sections defines the [payout of a product](DefiningProductsOverview.md#Payouts). +This sections defines the [payout of a product](DefiningProductsOverview.md#Payouts). ![Populating **Payouts** fields for Products in the **IAP Catalog** GUI](images/Payouts.png) @@ -48,7 +48,7 @@ Provide either a Product price, or an ID for a [Pricing Template](https://suppor ### Apple Configuration (required for Apple export) Select a **Pricing Tier** from the dropdown menu. Unity supports predefined Apple price points, but not arbitrary values. -__Select a screenshot__ to upload. +__Select a screenshot__ to upload. For information on screenshot specs, see Apple’s publisher support documentation. diff --git a/Documentation~/UnityIAPFetchingProductsIncrementally.md b/Documentation~/UnityIAPFetchingProductsIncrementally.md index 598a13a..232eb67 100644 --- a/Documentation~/UnityIAPFetchingProductsIncrementally.md +++ b/Documentation~/UnityIAPFetchingProductsIncrementally.md @@ -12,26 +12,26 @@ An alternative is to initialise Unity IAP with an initial set of products, then /// public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - var additional = new HashSet() { - new ProductDefinition("coins.500", ProductType.Consumable), - new ProductDefinition("armour", ProductType.NonConsumable) - }; - - Action onSuccess = () => { - Debug.Log("Fetched successfully!"); - // The additional products are added to the set of - // previously retrieved products and are browseable - // and purchasable. - foreach (var product in controller.products.all) { - Debug.Log(product.definition.id); - } - }; - - Action onFailure = (InitializationFailureReason i) => { - Debug.Log("Fetching failed for the specified reason: " + i); - }; - - controller.FetchAdditionalProducts(additional, onSuccess, onFailure); + var additional = new HashSet() { + new ProductDefinition("coins.500", ProductType.Consumable), + new ProductDefinition("armour", ProductType.NonConsumable) + }; + + Action onSuccess = () => { + Debug.Log("Fetched successfully!"); + // The additional products are added to the set of + // previously retrieved products and are browseable + // and purchasable. + foreach (var product in controller.products.all) { + Debug.Log(product.definition.id); + } + }; + + Action onFailure = (InitializationFailureReason i) => { + Debug.Log("Fetching failed for the specified reason: " + i); + }; + + controller.FetchAdditionalProducts(additional, onSuccess, onFailure); } ```` diff --git a/Documentation~/UnityIAPGoogleConfiguration.md b/Documentation~/UnityIAPGoogleConfiguration.md index d570a7c..385d478 100644 --- a/Documentation~/UnityIAPGoogleConfiguration.md +++ b/Documentation~/UnityIAPGoogleConfiguration.md @@ -2,9 +2,9 @@ ## Introduction -This guide describes the process of establishing the digital records and relationships necessary for a Unity game to interact with an In-App Purchase Store. +This guide describes the process of establishing the digital records and relationships necessary for a Unity game to interact with an In-App Purchase Store. -In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows the purchase of Products, representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought once) are the most common. +In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows the purchase of Products, representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought once) are the most common. ## Google Play Store @@ -12,13 +12,13 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p 1. Write a game implementing Unity IAP. See [Unity IAP Initialization](Overview.md) and [the Sample IAP Project](https://forum.unity.com/threads/sample-iap-project.529555/). -2. Keep the game's product identifiers on-hand for Google Play Developer Console use later. +2. Keep the game's product identifiers on-hand for Google Play Developer Console use later. ![gold50](images/IAPGoogleImage0.png) -3. Build a signed non-Development Build Android APK from your game. +3. Build a signed non-Development Build Android APK from your game. - **TIP:** Make sure you safely store your keystore file. The original keystore is always required to update a published Google Play application. + **TIP:** Make sure you safely store your keystore file. The original keystore is always required to update a published Google Play application. **TIP:** Reuse the Bundle Version Code from your last uploaded APK during local testing to permit side-loading without first being required to upload the changed APK to the Developer Console. See the settings for the Android platform Player. @@ -26,13 +26,13 @@ In-App Purchase (IAP) is the process of transacting money for digital goods. A p From the Google Account that will publish the game, register the Android application with the [Google Play Developer Console](https://play.google.com/apps/publish). -**NOTE:** This guide uses the [Google Play License Testing approach](http://developer.android.com/google/play/billing/billing_testing.html) for testing in-app purchase integration. +**NOTE:** This guide uses the [Google Play License Testing approach](http://developer.android.com/google/play/billing/billing_testing.html) for testing in-app purchase integration. 1. Choose __Create app__. ![All apps](images/IAPGoogleImage1.png) -2. Give the application an App name and select the appropriate options for your game. +2. Give the application an App name and select the appropriate options for your game. ![Create app](images/IAPGoogleImage2.png) @@ -48,26 +48,26 @@ Now that you have uploaded our first binary, you can add the IAP products. ![In-app products](images/IAPGoogleImage4.png) -2. Define the __Product ID__ , product details and price. Remember to activate the product after saving. +2. Define the __Product ID__ , product details and price. Remember to activate the product after saving. You can specify a consumable or non-consumable Product Type in __Managed product__. __Subscription__ is also supported by Unity IAP. -**NOTE**: The "Product ID" here is the same identifier used in the game source code, added to the [Unity IAP ConfigurationBuilder] instance via `AddProduct()` or `AddProducts()`, like "gold50". +**NOTE**: The "Product ID" here is the same identifier used in the game source code, added to the [Unity IAP ConfigurationBuilder] instance via `AddProduct()` or `AddProducts()`, like "gold50". ![50goldcoins](images/IAPGoogleImage5.png) ### Test IAP -Add your testers to License Testing. +Add your testers to License Testing. -1. Navigate to All Apps on your Google Developer dashboard. +1. Navigate to All Apps on your Google Developer dashboard. -2. Select __Settings/License Testing__. Add each Google Account email address. Save changes. +2. Select __Settings/License Testing__. Add each Google Account email address. Save changes. ![License testing](images/IAPGoogleImage6.png) - NOTE: There may be a delay of several hours from the time you publish the APK. - + NOTE: There may be a delay of several hours from the time you publish the APK. + 3. When available, share the __Join on Android__ link with testers. Ensure that testers can install the application from the store. __Note:__ To test updates retaining permission to purchase IAPS's for free, you may side-load applications, updating the existing store-downladed APK install. @@ -78,5 +78,5 @@ __Note:__ To test updates retaining permission to purchase IAPS's for free, you 4. To test the IAP, make a purchase on a device logged in with a Tester Google Account. A modified purchase dialog box appears to confirm the fact this product is under test and is free. **WARNING**: If this dialog box does not appear, then the Tester Google Account will be charged real money for the product. - + ![](images/IAPGoogleImage8.png) diff --git a/Documentation~/UnityIAPGooglePlay.md b/Documentation~/UnityIAPGooglePlay.md index 5efab53..0e42ca7 100644 --- a/Documentation~/UnityIAPGooglePlay.md +++ b/Documentation~/UnityIAPGooglePlay.md @@ -12,7 +12,7 @@ Extended functionality ### Listen for recoverable initialization interruptions -A game may not complete initializing Unity IAP, either successfully or unsuccessfully, in certain circumstances. This can be due to the user having no Google account added to their Android device when the game initializes Unity IAP. +A game may not complete initializing Unity IAP, either successfully or unsuccessfully, in certain circumstances. This can be due to the user having no Google account added to their Android device when the game initializes Unity IAP. For example: a user first installs the app with the Play Store. Then the user removes their Google account from the device. The user launches the game and Unity IAP does not finish initializing, preventing the user from purchasing or restoring any prior purchases. To fix this, the user can [add a Google account](https://support.google.com/android/answer/7664951) to their device and return to the game. diff --git a/Documentation~/UnityIAPHandlingPurchaseFailures.md b/Documentation~/UnityIAPHandlingPurchaseFailures.md index 1a098ab..2c52fb4 100644 --- a/Documentation~/UnityIAPHandlingPurchaseFailures.md +++ b/Documentation~/UnityIAPHandlingPurchaseFailures.md @@ -14,4 +14,3 @@ public void OnPurchaseFailed (Product i, PurchaseFailureReason p) } } ```` - diff --git a/Documentation~/UnityIAPIStoreHandlingPurchases.md b/Documentation~/UnityIAPIStoreHandlingPurchases.md index 0162257..2640e90 100644 --- a/Documentation~/UnityIAPIStoreHandlingPurchases.md +++ b/Documentation~/UnityIAPIStoreHandlingPurchases.md @@ -11,6 +11,3 @@ Finishing Transactions When the application acknowledges that a transaction has been processed, or if the transaction has already been processed, Unity IAP invokes your store’s FinishTransaction method. Stores should use FinishTransaction to perform any housekeeping following a purchase, such as closing transactions or consuming consumable products. - - - diff --git a/Documentation~/UnityIAPIStoreInitialization.md b/Documentation~/UnityIAPIStoreInitialization.md index 897f4ca..a914486 100644 --- a/Documentation~/UnityIAPIStoreInitialization.md +++ b/Documentation~/UnityIAPIStoreInitialization.md @@ -9,4 +9,3 @@ void Initialize(IStoreCallback callback) { this.callback = callback; } ```` - diff --git a/Documentation~/UnityIAPIStoreRetrievingProducts.md b/Documentation~/UnityIAPIStoreRetrievingProducts.md index 8936fd9..6ad4c37 100644 --- a/Documentation~/UnityIAPIStoreRetrievingProducts.md +++ b/Documentation~/UnityIAPIStoreRetrievingProducts.md @@ -13,4 +13,3 @@ Handling errors --------------- If products cannot be retrieved due to an unrecoverable error, such as the developer making an error with their store configuration, you should call the ``OnSetupFailed`` method of the ``IStoreCallback``, indicating the ``InitializationFailureReason`` responsible. - diff --git a/Documentation~/UnityIAPImplementingAStore.md b/Documentation~/UnityIAPImplementingAStore.md index 0e8c0fb..78d595b 100644 --- a/Documentation~/UnityIAPImplementingAStore.md +++ b/Documentation~/UnityIAPImplementingAStore.md @@ -11,7 +11,7 @@ public class MyStore : IStore private IStoreCallback callback; public void Initialize (IStoreCallback callback) { - this.callback = callback; + this.callback = callback; } public void RetrieveProducts (System.Collections.ObjectModel.ReadOnlyCollection products) @@ -26,8 +26,7 @@ public class MyStore : IStore public void FinishTransaction (UnityEngine.Purchasing.ProductDefinition product, string transactionId) { - // Perform transaction related housekeeping + // Perform transaction related housekeeping } } ```` - diff --git a/Documentation~/UnityIAPInitializeUnityGamingServices.md b/Documentation~/UnityIAPInitializeUnityGamingServices.md index 5f5dd79..5ca0091 100644 --- a/Documentation~/UnityIAPInitializeUnityGamingServices.md +++ b/Documentation~/UnityIAPInitializeUnityGamingServices.md @@ -1,5 +1,6 @@ -### Initialize Unity Gaming Services -Call `UnityServices.InitializeAsync()` to initialize all Unity Gaming Services at once. +# Initialize Unity Gaming Services + +Call `UnityServices.InitializeAsync()` to initialize all **Unity Gaming Services** at once. It returns a `Task` that enables you to monitor the initialization's progression. #### Example @@ -24,14 +25,39 @@ public class InitializeUnityServices : MonoBehaviour } catch (Exception exception) { - // An error occured during services initialization. + // An error occurred during services initialization. } } } ``` +### Automatic initialization + +Instead, you may enable **Unity Gaming Services** automatic initialization by checking the **Automatically initialize Unity Gaming Services** checkbox at the bottom of the **IAP Catalog** window. +This ensures that **Unity Gaming Services** initializes immediately when the application starts. +![Enabling auto-initialization for the Unity Gaming Services through the **IAP Catalog** GUI](images/AutoInitializeUGS.png) +To use this feature **Automatically initialize UnityPurchasing (recommended)** must be enabled. + +This initializes **Unity Gaming Services** with the default `production` environment. +This way of initializing **Unity Gaming Services** might not be compatible with all other services as they might require special initialization options. +If the use of initialization options is needed, **Unity Gaming Services** should be initialized with the coded API as described above. + +## Warning message + +If you attempt to use the **Unity IAP** service without first initializing **Unity Gaming Services**, you will receive the following warning message: +``` +Unity In-App Purchasing requires Unity Gaming Services to have been initialized before use. +Find out how to initialize Unity Gaming Services by following the documentation https://docs.unity.com/ugs-overview/services-core-api.html#InitializationExample +or download the 06 Initialize Gaming Services sample from Package Manager > In-App Purchasing > Samples. +``` + ## Technical details The `InitializeAsync` methods affect the currently installed service packages in your Unity project. Note that this method is not supported during edit time. + +___ +For more information, please see the [Services Core API documentation](https://docs.unity.com/ugs-overview/services-core-api.html#Services_Core_API). + +Download the `06 Initialize Gaming Services` from `Package Manager > In-App Purchasing > Samples` for a concrete example. diff --git a/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta b/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta deleted file mode 100644 index b1e2cf7..0000000 --- a/Documentation~/UnityIAPInitializeUnityGamingServices.md.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 60c3a76fd50646498ce7b03defa9439c -timeCreated: 1649358944 \ No newline at end of file diff --git a/Documentation~/UnityIAPInitiatingPurchases.md b/Documentation~/UnityIAPInitiatingPurchases.md index ff0936c..30a90f5 100644 --- a/Documentation~/UnityIAPInitiatingPurchases.md +++ b/Documentation~/UnityIAPInitiatingPurchases.md @@ -11,4 +11,3 @@ public void OnPurchaseClicked(string productId) { ```` Your application will be notified asynchronously of the result, either with an invocation of ``ProcessPurchase`` for successful purchases or ``OnPurchaseFailed`` for failures. - diff --git a/Documentation~/UnityIAPModuleConfiguration.md b/Documentation~/UnityIAPModuleConfiguration.md index 778dca9..56761b0 100644 --- a/Documentation~/UnityIAPModuleConfiguration.md +++ b/Documentation~/UnityIAPModuleConfiguration.md @@ -9,4 +9,3 @@ BindConfiguration(new MyConfiguration()); ```` When developers request an instance of your configuration type, Unity IAP first tries to cast your store implementation to the configuration type. Only if that cast fails will any instance bound via ``BindConfiguration`` will be used. - diff --git a/Documentation~/UnityIAPModuleExtension.md b/Documentation~/UnityIAPModuleExtension.md index dfca489..4fefea3 100644 --- a/Documentation~/UnityIAPModuleExtension.md +++ b/Documentation~/UnityIAPModuleExtension.md @@ -20,5 +20,3 @@ Applications request extended functionality via the ``IExtensionProvider``. When If that cast fails, Unity IAP will provide any instance registered via a call your store module has provided via ``RegisterExtension``, or null if no instance has been provided. Modules should provide instances for the extension interfaces they define even when running on unsupported platforms, so as to avoid forcing application developers to use platform dependent compilation. - - diff --git a/Documentation~/UnityIAPModuleRegistration.md b/Documentation~/UnityIAPModuleRegistration.md index 01cd310..804851d 100644 --- a/Documentation~/UnityIAPModuleRegistration.md +++ b/Documentation~/UnityIAPModuleRegistration.md @@ -17,5 +17,3 @@ private void InstantiateMyStore() { ```` The store name must match the name developers use when defining products for your store so Unity IAP uses the correct product identifiers when addressing your store. - - diff --git a/Documentation~/UnityIAPModules.md b/Documentation~/UnityIAPModules.md index e8e5e3a..ae61844 100644 --- a/Documentation~/UnityIAPModules.md +++ b/Documentation~/UnityIAPModules.md @@ -12,4 +12,3 @@ ConfigurationBuilder.Instance (MyCustomModule.Instance(), StandardPurchasingModu Where two or more modules have implementations available for a given platform, precedence is given in order the modules were supplied to the ``ConfigurationBuilder``; any implementation provided by ``MyCustomModule`` will be used in preference to ``StandardPurchasingModule``. Note that a module can support multiple stores; the ``StandardPurchasingModule`` handles all of Unity IAPs default store implementations. - diff --git a/Documentation~/UnityIAPProcessingPurchases.md b/Documentation~/UnityIAPProcessingPurchases.md index 73e7791..e512d61 100644 --- a/Documentation~/UnityIAPProcessingPurchases.md +++ b/Documentation~/UnityIAPProcessingPurchases.md @@ -32,5 +32,3 @@ If you are saving consumable purchases to the cloud, you **must** return `Purcha When returning `Pending`, Unity IAP keeps transactions open on the underlying store until confirmed as processed, ensuring consumable purchases are not lost even if a user reinstalls your application while a consumable is in this state. ![Pending Purchases](images/PurchaseProcessingResult.Pending.png) - - diff --git a/Documentation~/UnityIAPPurchaseReceipts.md b/Documentation~/UnityIAPPurchaseReceipts.md index 104b21b..2fadcdc 100644 --- a/Documentation~/UnityIAPPurchaseReceipts.md +++ b/Documentation~/UnityIAPPurchaseReceipts.md @@ -1,4 +1,4 @@ -# Purchase Receipts +# Purchase Receipts Unity IAP provides purchase receipts as a JSON hash containing the following keys and values: diff --git a/Documentation~/UnityIAPRestoringTransactions.md b/Documentation~/UnityIAPRestoringTransactions.md index 0c4e3a8..461ad51 100644 --- a/Documentation~/UnityIAPRestoringTransactions.md +++ b/Documentation~/UnityIAPRestoringTransactions.md @@ -12,14 +12,13 @@ On Apple platforms users must enter their password to retrieve previous transact /// public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - extensions.GetExtension ().RestoreTransactions (result => { - if (result) { - // This does not mean anything was restored, - // merely that the restoration process succeeded. - } else { - // Restoration failed. - } - }); + extensions.GetExtension ().RestoreTransactions (result => { + if (result) { + // This does not mean anything was restored, + // merely that the restoration process succeeded. + } else { + // Restoration failed. + } + }); } ```` - diff --git a/Documentation~/UnityIAPUniversalWindows.md b/Documentation~/UnityIAPUniversalWindows.md index 69898e4..c37aee6 100644 --- a/Documentation~/UnityIAPUniversalWindows.md +++ b/Documentation~/UnityIAPUniversalWindows.md @@ -11,4 +11,3 @@ var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance()) builder.Configure().useMockBillingSystem = true; ```` Make sure you disable the mock billing system before publishing your application. - diff --git a/Documentation~/UnityIAPValidatingReceipts.md b/Documentation~/UnityIAPValidatingReceipts.md index a579d20..bc05cf2 100644 --- a/Documentation~/UnityIAPValidatingReceipts.md +++ b/Documentation~/UnityIAPValidatingReceipts.md @@ -76,16 +76,16 @@ public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs e) ### Choose an Apple certificate: Apple Root or StoreKit Test -(*) Unity IAP supports receipt validation of purchases made with the StoreKit Test store simulation. +(*) Unity IAP supports receipt validation of purchases made with the StoreKit Test store simulation. Apple's Xcode 12 offers the ["StoreKit Test"](https://developer.apple.com/documentation/Xcode/setting-up-storekit-testing-in-xcode) suite of features for developers to more conveniently test IAP, without the need to use an Apple App Store Connect Sandbox configuration. -Use the `AppleStoreKitTestTangle` class in place of the usual `AppleTangle` class, when constructing the `CrossPlatformValidator` for receipt validation. Note that both tangle classes are generated by the **Receipt Validation Obfuscator**. +Use the `AppleStoreKitTestTangle` class in place of the usual `AppleTangle` class, when constructing the `CrossPlatformValidator` for receipt validation. Note that both tangle classes are generated by the **Receipt Validation Obfuscator**. ```` public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs e) { - bool validPurchase = true; + bool validPurchase = true; #if UNITY_ANDROID || UNITY_IOS || UNITY_STANDALONE_OSX // Choose one Apple certificate. NOTE AppleStoreKitTestTangle requires @@ -129,27 +129,27 @@ Different stores have different fields in their purchase receipts. To access sto var result = validator.Validate(e.purchasedProduct.receipt); Debug.Log("Receipt is valid. Contents:"); foreach (IPurchaseReceipt productReceipt in result) { - Debug.Log(productReceipt.productID); - Debug.Log(productReceipt.purchaseDate); + Debug.Log(productReceipt.productID); + Debug.Log(productReceipt.purchaseDate); Debug.Log(productReceipt.transactionID); - GooglePlayReceipt google = productReceipt as GooglePlayReceipt; - if (null != google) { - // This is Google's Order ID. - // Note that it is null when testing in the sandbox - // because Google's sandbox does not provide Order IDs. - Debug.Log(google.transactionID); - Debug.Log(google.purchaseState); - Debug.Log(google.purchaseToken); - } - - AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt; - if (null != apple) { - Debug.Log(apple.originalTransactionIdentifier); - Debug.Log(apple.subscriptionExpirationDate); - Debug.Log(apple.cancellationDate); - Debug.Log(apple.quantity); - } + GooglePlayReceipt google = productReceipt as GooglePlayReceipt; + if (null != google) { + // This is Google's Order ID. + // Note that it is null when testing in the sandbox + // because Google's sandbox does not provide Order IDs. + Debug.Log(google.transactionID); + Debug.Log(google.purchaseState); + Debug.Log(google.purchaseToken); + } + + AppleInAppPurchaseReceipt apple = productReceipt as AppleInAppPurchaseReceipt; + if (null != apple) { + Debug.Log(apple.originalTransactionIdentifier); + Debug.Log(apple.subscriptionExpirationDate); + Debug.Log(apple.cancellationDate); + Debug.Log(apple.quantity); + } } ```` @@ -168,8 +168,8 @@ AppleReceipt receipt = new AppleValidator(AppleTangle.Data()).Validate(receiptDa Debug.Log(receipt.bundleID); Debug.Log(receipt.receiptCreationDate); foreach (AppleInAppPurchaseReceipt productReceipt in receipt.inAppPurchaseReceipts) { - Debug.Log(productReceipt.transactionIdentifier); - Debug.Log(productReceipt.productIdentifier); + Debug.Log(productReceipt.transactionIdentifier); + Debug.Log(productReceipt.productIdentifier); } #endif ```` diff --git a/Documentation~/UnityIAPWindowsConfiguration.md b/Documentation~/UnityIAPWindowsConfiguration.md index 13c0038..f4dcd7e 100644 --- a/Documentation~/UnityIAPWindowsConfiguration.md +++ b/Documentation~/UnityIAPWindowsConfiguration.md @@ -4,15 +4,15 @@ This guide describes the process of establishing the digital records and relationships necessary for a Unity game to interact with an In-App Purchase Store. -In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows purchase of Products representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought once) are the most common. +In-App Purchase (IAP) is the process of transacting money for digital goods. A platform's Store allows purchase of Products representing digital goods. These Products have an Identifier, typically of string datatype. Products have Types to represent their durability: _subscription_, _consumable_ (capable of being rebought), and _non-consumable_ (capable of being bought once) are the most common. ## Windows Store ### Introduction -Windows App Development offers both local and remote Windows Store client-server IAP testing. +Windows App Development offers both local and remote Windows Store client-server IAP testing. -This page covers local testing with the emulator and a simulated billing system, then Windows Store testing which limits app publication visibility to those with the app's link. +This page covers local testing with the emulator and a simulated billing system, then Windows Store testing which limits app publication visibility to those with the app's link. **NOTE**: This guide targets Windows 10 Universal SDK. Other Windows targets are available. @@ -22,19 +22,19 @@ This page covers local testing with the emulator and a simulated billing system, 1. Write a game implementing Unity IAP. See [Unity IAP Initialization](Overview.md). -1. Keep the game's product identifiers on-hand for use in Microsoft's Windows Dev Center Dashboard to perform remote Windows Store testing later. +1. Keep the game's product identifiers on-hand for use in Microsoft's Windows Dev Center Dashboard to perform remote Windows Store testing later. ![](images/IAPWindowsImage0.png) ### Test IAP locally -Microsoft offers a simulated billing system, permitting local testing of IAP. This removes the need to configure anything on the Windows Dev Center or communicate with the the Windows Store via the app for initial integration testing. +Microsoft offers a simulated billing system, permitting local testing of IAP. This removes the need to configure anything on the Windows Dev Center or communicate with the the Windows Store via the app for initial integration testing. -[Configuring local testing](UnityIAPUniversalWindows.md) is far simpler than for remote Store testing, although it requires temporary code changes to the app which need to be removed before app publication. +[Configuring local testing](UnityIAPUniversalWindows.md) is far simpler than for remote Store testing, although it requires temporary code changes to the app which need to be removed before app publication. To test IAP locally: -1. Enable the simulated billing system in code where Unity IAP is initialized with its ConfigurationBuilder instance. +1. Enable the simulated billing system in code where Unity IAP is initialized with its ConfigurationBuilder instance. **WARNING**: Remove these code changes after testing, before publishing to the Store; otherwise the app will not transact any real money via IAP! @@ -52,7 +52,7 @@ To test IAP locally: Once basic IAP functionality has been tested locally, you can more confidently begin working with the Windows Store. This test confirms that the app has all necessary IAPs registered correctly to permit purchasing. -For testing IAP and publication use the [Windows Dev Center](https://dev.windows.com/en-us/publish) and configure the app with a limited visibility. This limits the app's visibility to those who have its direct link. +For testing IAP and publication use the [Windows Dev Center](https://dev.windows.com/en-us/publish) and configure the app with a limited visibility. This limits the app's visibility to those who have its direct link. **NOTE**: Testing on the Store also requires Certification, which may serve as an obstacle to testing. It is therefore important to complete testing locally before proceeding to testing with Windows Store. @@ -60,7 +60,7 @@ For testing IAP and publication use the [Windows Dev Center](https://dev.windows ![](images/IAPWindowsImage2.png) -2. Reserve the app name. +2. Reserve the app name. ![](images/IAPWindowsImage3.png) @@ -68,21 +68,21 @@ For testing IAP and publication use the [Windows Dev Center](https://dev.windows ![](images/IAPWindowsImage4.png) -4. In "Distribution and visibility" see a list of the Store's available [publication behaviors](https://msdn.microsoft.com/en-us/library/windows/apps/mt148548.aspx#dist_vis). Select __Hide this app in the Store__. +4. In "Distribution and visibility" see a list of the Store's available [publication behaviors](https://msdn.microsoft.com/en-us/library/windows/apps/mt148548.aspx#dist_vis). Select __Hide this app in the Store__. ![](images/IAPWindowsImage5.png) -5. Collect the direct link. This will be used to install the app on a Windows 10 device for [testing](https://msdn.microsoft.com/en-us/library/windows/apps/mt148561.aspx). +5. Collect the direct link. This will be used to install the app on a Windows 10 device for [testing](https://msdn.microsoft.com/en-us/library/windows/apps/mt148561.aspx). ![](images/IAPWindowsImage6.png) -6. Submit the app for Certification. +6. Submit the app for Certification. Submissions may take many hours to complete, and blocking issues may be raised by Microsoft Certification, which you will need to address before the submission passes successfully. ### Add In-App Products on the Store -Add each IAP, setting the price to be "free" so that no money will be transacted during testing. After the test is completed, reconfigure the IAP with the desired price and republish it. See [IAP Submissions](https://msdn.microsoft.com/en-us/library/windows/apps/mt148551.aspx). +Add each IAP, setting the price to be "free" so that no money will be transacted during testing. After the test is completed, reconfigure the IAP with the desired price and republish it. See [IAP Submissions](https://msdn.microsoft.com/en-us/library/windows/apps/mt148551.aspx). 1. In the new app's "App overview" page, click __Create a new IAP__ . @@ -92,7 +92,7 @@ Add each IAP, setting the price to be "free" so that no money will be transacted ![](images/IAPWindowsImage8.png) -3. Configure the type, price, and language. +3. Configure the type, price, and language. **NOTE**: For **Pricing and availability** choose **free** for testing purposes to avoid incurring unnecessary financial charges. When you're finished with testing, update and re-submit each IAP with the desired price in preparation for release to the public. @@ -113,12 +113,12 @@ Add each IAP, setting the price to be "free" so that no money will be transacted Select the declared language when returned to the IAP overview. ![](images/IAPWindowsImage13.png) - - Populate the Title, Description and Icon. + + Populate the Title, Description and Icon. ![](images/IAPWindowsImage14.png) -4. Submit the IAP for Certification. +4. Submit the IAP for Certification. As with apps, IAP submissions may take many hours to complete, and blocking issues may be raised by Microsoft Certification which you will need to address before the submission passes successfully. @@ -135,5 +135,3 @@ These steps follow a branch of the beta test process made possible with Windows 3. Test IAP. 4. After passing test, update the IAP with the desired public pricing, update the app visibility settings to share with the general public, and submit both kinds of changes for final Certification. - - diff --git a/Documentation~/UnityIAPiOSMAS.md b/Documentation~/UnityIAPiOSMAS.md index f93213b..e227369 100644 --- a/Documentation~/UnityIAPiOSMAS.md +++ b/Documentation~/UnityIAPiOSMAS.md @@ -32,14 +32,14 @@ On Apple platforms users must enter their password to retrieve previous transact /// public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - extensions.GetExtension ().RestoreTransactions (result => { - if (result) { - // This does not mean anything was restored, - // merely that the restoration process succeeded. - } else { - // Restoration failed. - } - }); + extensions.GetExtension ().RestoreTransactions (result => { + if (result) { + // This does not mean anything was restored, + // merely that the restoration process succeeded. + } else { + // Restoration failed. + } + }); } ```` @@ -57,22 +57,22 @@ Unity IAP makes this method available as follows: /// public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { - extensions.GetExtension ().RefreshAppReceipt (receipt => { - // This handler is invoked if the request is successful. - // Receipt will be the latest app receipt. - Console.WriteLine(receipt); - }, - () => { - // This handler will be invoked if the request fails, - // such as if the network is unavailable or the user - // enters the wrong password. - }); + extensions.GetExtension ().RefreshAppReceipt (receipt => { + // This handler is invoked if the request is successful. + // Receipt will be the latest app receipt. + Console.WriteLine(receipt); + }, + () => { + // This handler will be invoked if the request fails, + // such as if the network is unavailable or the user + // enters the wrong password. + }); } ```` ### Ask to Buy -iOS 8 introduced a new parental control feature called [Ask to Buy](https://developer.apple.com/library/ios/technotes/tn2259/_index.html#/apple_ref/doc/uid/DTS40009578-CH1-UPDATE_YOUR_APP_FOR_ASK_TO_BUY). +iOS 8 introduced a new parental control feature called [Ask to Buy](https://developer.apple.com/library/ios/technotes/tn2259/_index.html#/apple_ref/doc/uid/DTS40009578-CH1-UPDATE_YOUR_APP_FOR_ASK_TO_BUY). Ask to Buy purchases defer for parent approval. When this occurs, Unity IAP sends your app a notification as follows: @@ -95,19 +95,19 @@ using UnityEngine; using UnityEngine.Purchasing; public class AppleSimulateAskToBuy : MonoBehaviour { - public void SetSimulateAskToBuy(bool shouldSimulateAskToBuy) { - if (Application.platform == RuntimePlatform.IPhonePlayer) { - IAppleExtensions extensions = IAPButton.IAPButtonStoreManager.Instance.ExtensionProvider.GetExtension(); - extensions.simulateAskToBuy = shouldSimulateAskToBuy; - } - } + public void SetSimulateAskToBuy(bool shouldSimulateAskToBuy) { + if (Application.platform == RuntimePlatform.IPhonePlayer) { + IAppleExtensions extensions = IAPButton.IAPButtonStoreManager.Instance.ExtensionProvider.GetExtension(); + extensions.simulateAskToBuy = shouldSimulateAskToBuy; + } + } } ``` When the purchase is approved or rejected, your store's normal `ProcessPurchase` or `OnPurchaseFailed` listener methods are invoked. ### Transaction Receipts -Sometimes consumable Ask to Buy purchases don't show up in the App Receipt, in which case you cannot validate them using that receipt. However, iOS provides a Transaction Receipt that contains all purchases, including Ask to Buy. Access the most recent Transaction Receipt string for a given `Product` using `IAppleExtensions`. +Sometimes consumable Ask to Buy purchases don't show up in the App Receipt, in which case you cannot validate them using that receipt. However, iOS provides a Transaction Receipt that contains all purchases, including Ask to Buy. Access the most recent Transaction Receipt string for a given `Product` using `IAppleExtensions`. **Note**: Transaction Receipts are not available for Mac builds. Requesting a Transaction Receipt on a Mac build results in an empty string. @@ -156,7 +156,7 @@ public class AskToBuy : MonoBehaviour, IStoreListener string transactionReceipt = m_AppleExtensions.GetTransactionReceiptForProduct (e.purchasedProduct); Console.WriteLine (transactionReceipt); // Send transaction receipt to server for validation - } + } return PurchaseProcessingResult.Complete; } @@ -223,11 +223,11 @@ Example JSON response: ``` ## Intercepting Apple promotional purchases -Apple allows you to promote [in-game purchases](https://developer.apple.com/app-store/promoting-in-app-purchases/#:~:text=inside%20your%20app.-,Overview,approved%20and%20ready%20to%20promote.&text=When%20a%20user%20doesn't,to%20download%20the%20app%20first.) through your app’s product page. Unlike conventional in-app purchases, Apple promotional purchases initiate directly from the App Store on iOS and tvOS. The App Store then launches your app to complete the transaction, or prompts the user to download the app if it isn’t installed. +Apple allows you to promote [in-game purchases](https://developer.apple.com/app-store/promoting-in-app-purchases/#:~:text=inside%20your%20app.-,Overview,approved%20and%20ready%20to%20promote.&text=When%20a%20user%20doesn't,to%20download%20the%20app%20first.) through your app’s product page. Unlike conventional in-app purchases, Apple promotional purchases initiate directly from the App Store on iOS and tvOS. The App Store then launches your app to complete the transaction, or prompts the user to download the app if it isn’t installed. The `IAppleConfiguration` `SetApplePromotionalPurchaseInterceptor` callback method intercepts Apple promotional purchases. Use this callback to present parental gates, send analytics events, or perform other functions before sending the purchase to Apple. The callback uses the `Product` that the user attempted to purchase. You must call `IAppleExtensions.ContinuePromotionalPurchases()` to continue with the promotional purchase. This will initiate any queued-up payments. -If you do not set the callback, promotional purchases go through immediately and call `ProcessPurchase` with the result. +If you do not set the callback, promotional purchases go through immediately and call `ProcessPurchase` with the result. **Note**: Calling these APIs on other platforms has no effect. @@ -249,7 +249,7 @@ public void Awake() { public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { m_AppleExtensions = extensions.GetExtension(); foreach (var item in controller.products.all) { - if (item.availableToPurchase) { + if (item.availableToPurchase) { // Set all these products to be visible in the user's App Store m_AppleExtensions.SetStorePromotionVisibility(item, AppleStorePromotionVisibility.Show); } @@ -258,9 +258,9 @@ public void OnInitialized(IStoreController controller, IExtensionProvider extens private void OnPromotionalPurchase(Product item) { Debug.Log("Attempted promotional purchase: " + item.definition.id); - // Promotional purchase has been detected. + // Promotional purchase has been detected. // Handle this event by, e.g. presenting a parental gate. - // Here, for demonstration purposes only, we will wait five seconds before continuing + // Here, for demonstration purposes only, we will wait five seconds before continuing // the purchase. StartCoroutine(ContinuePromotionalPurchases()); } @@ -272,4 +272,4 @@ private IEnumerator ContinuePromotionalPurchases() { m_AppleExtensions.ContinuePromotionalPurchases (); // iOS and tvOS only } -``` \ No newline at end of file +``` diff --git a/Documentation~/WhatCustomStore.md b/Documentation~/WhatCustomStore.md index 2517bdc..58c8fd3 100644 --- a/Documentation~/WhatCustomStore.md +++ b/Documentation~/WhatCustomStore.md @@ -1,4 +1,3 @@ # What is a Custom Store A custom store is a store that the In-App Purchasing package doesn't support out of the out of the box. It allows developers to create their own stores and build on top of the existing infrastructure of the package. - diff --git a/Documentation~/WhatIsFakeStore.md b/Documentation~/WhatIsFakeStore.md index 2346607..c41ce34 100644 --- a/Documentation~/WhatIsFakeStore.md +++ b/Documentation~/WhatIsFakeStore.md @@ -1,3 +1,3 @@ # What is Fake Store? -Fake Store isn't a store that exists in production, it is used to tests your apps quickly inside the editor. \ No newline at end of file +Fake Store isn't a store that exists in production, it is used to tests your apps quickly inside the editor. diff --git a/Documentation~/images/AutoInitialize.png b/Documentation~/images/AutoInitialize.png index c192232cf8d9563d3cf86aaded256e3c2af91282..dfb9848648e2402460eca063c20dd71c963a5ede 100644 GIT binary patch literal 38223 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 literal 11848 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 CiYkHt literal 0 HcmV?d00001 diff --git a/Documentation~/images/UGSInitializationFlowDiagram.png b/Documentation~/images/UGSInitializationFlowDiagram.png new file mode 100644 index 0000000000000000000000000000000000000000..14f8a2d2ed8185cd313527767de0326117b70e9e GIT binary patch literal 6804 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 literal 0 HcmV?d00001 diff --git a/Editor/Analytics/Entity/EventUINames.cs b/Editor/Analytics/Entity/EventUINames.cs index 3d9567d..0022352 100644 --- a/Editor/Analytics/Entity/EventUINames.cs +++ b/Editor/Analytics/Entity/EventUINames.cs @@ -3,6 +3,7 @@ namespace UnityEditor.Purchasing internal static class EventUINames { internal const string k_UINameAutoInit = "auto_init_purchasing"; + internal const string k_UINameUgsAutoInit = "auto_init_ugs"; internal const string k_UINameSelectTargetStore = "select_target_store"; internal const string k_UINameProductType = "product_type"; internal const string k_UINamePayoutType = "payout_type"; diff --git a/Editor/Analytics/Helpers/GenericEditorClickCheckboxEventSenderHelpers.cs b/Editor/Analytics/Helpers/GenericEditorClickCheckboxEventSenderHelpers.cs index bfaf86a..cb0a9d6 100644 --- a/Editor/Analytics/Helpers/GenericEditorClickCheckboxEventSenderHelpers.cs +++ b/Editor/Analytics/Helpers/GenericEditorClickCheckboxEventSenderHelpers.cs @@ -7,6 +7,11 @@ internal static void SendCatalogAutoInitToggleEvent(bool value) BuildAndSendEvent(EventComponents.k_ComponentCodeless, EventTools.k_ToolCatalog, EventUINames.k_UINameAutoInit, value); } + internal static void SendCatalogUgsAutoInitToggleEvent(bool value) + { + BuildAndSendEvent(EventComponents.k_ComponentCodeless, EventTools.k_ToolCatalog, EventUINames.k_UINameUgsAutoInit, value); + } + static void BuildAndSendEvent(string component, string tool, string name, bool value) { var newEvent = new GenericEditorClickCheckboxEvent(component, tool, name, value); diff --git a/Editor/AppStoreExtensionMethods.cs b/Editor/AppStoreExtensionMethods.cs index 351efc8..4ea9f4d 100644 --- a/Editor/AppStoreExtensionMethods.cs +++ b/Editor/AppStoreExtensionMethods.cs @@ -35,8 +35,8 @@ public static AppStore ToAppStoreFromDisplayName(this string value) public static bool IsAndroid(this AppStore value) { - return (int) value >= (int) AppStoreMeta.AndroidStoreStart && - (int) value <= (int) AppStoreMeta.AndroidStoreEnd; + return (int)value >= (int)AppStoreMeta.AndroidStoreStart && + (int)value <= (int)AppStoreMeta.AndroidStoreEnd; } } } diff --git a/Editor/ApplePriceTiers.cs b/Editor/ApplePriceTiers.cs index 0a956bb..f50a10e 100644 --- a/Editor/ApplePriceTiers.cs +++ b/Editor/ApplePriceTiers.cs @@ -1,76 +1,96 @@ -namespace UnityEditor.Purchasing +namespace UnityEditor.Purchasing { - internal static class ApplePriceTiers - { - internal const int kNumTiers = 88; + internal static class ApplePriceTiers + { + internal const int kNumTiers = 88; - // Cache - private static string [] s_Strings; - private static int [] s_Dollars; + // Cache + private static string[] s_Strings; + private static int[] s_Dollars; - internal static string [] Strings { - get { - GenerateAppleTierData (); - return s_Strings; - } - } + internal static string[] Strings + { + get + { + GenerateAppleTierData(); + return s_Strings; + } + } - internal static int [] RoundedDollars { - get { - GenerateAppleTierData (); - return s_Dollars; - } - } + internal static int[] RoundedDollars + { + get + { + GenerateAppleTierData(); + return s_Dollars; + } + } - internal static double ActualDollarsForAppleTier (int tier) - { - if (RoundedDollars[tier] == 0) - return 0; - - return RoundedDollars[tier] - 0.01; - } + internal static double ActualDollarsForAppleTier(int tier) + { + if (RoundedDollars[tier] == 0) + return 0; - private static void GenerateAppleTierData () - { - if (s_Strings == null || s_Dollars == null) { - s_Strings = new string [kNumTiers]; - s_Dollars = new int [kNumTiers]; + return RoundedDollars[tier] - 0.01; + } - var i = 0; - s_Dollars [i] = 0; - s_Strings [i++] = "Free"; + private static void GenerateAppleTierData() + { + if (s_Strings == null || s_Dollars == null) + { + s_Strings = new string[kNumTiers]; + s_Dollars = new int[kNumTiers]; - var dollars = 1; - for (; i < kNumTiers; ++i) { - if (i == 63) { - s_Strings [i] = CreateApplePriceTierString (i, 125); - s_Dollars [i] = 125; - } else if (i == 69) { - s_Strings [i] = CreateApplePriceTierString (i, 175); - s_Dollars [i] = 175; - } else { - s_Strings [i] = CreateApplePriceTierString (i, dollars); - s_Dollars [i] = dollars; + var i = 0; + s_Dollars[i] = 0; + s_Strings[i++] = "Free"; - if (i >= 82) { // 82 - 87 USD $100 increments to $1000 - dollars += 100; - } else if (i >= 77) { // 77 - 82 USD $50 increments to $500 - dollars += 50; - } else if (i >= 60) { // 60 - 77 $10 increments to $250, except 63 = $125 and 69 = $175 - dollars += 10; - } else if (i >= 50) { // 50 - 59 USD $5 increments - dollars += 5; - } else { // 1 - 49 USD $1 increments - dollars++; - } - } - } - } - } + var dollars = 1; + for (; i < kNumTiers; ++i) + { + if (i == 63) + { + s_Strings[i] = CreateApplePriceTierString(i, 125); + s_Dollars[i] = 125; + } + else if (i == 69) + { + s_Strings[i] = CreateApplePriceTierString(i, 175); + s_Dollars[i] = 175; + } + else + { + s_Strings[i] = CreateApplePriceTierString(i, dollars); + s_Dollars[i] = dollars; - private static string CreateApplePriceTierString (int tier, int roundedDollars) - { - return string.Format ("Tier {0} - USD {1:0.00}", tier, (float)roundedDollars - 0.01f); - } - } + if (i >= 82) + { // 82 - 87 USD $100 increments to $1000 + dollars += 100; + } + else if (i >= 77) + { // 77 - 82 USD $50 increments to $500 + dollars += 50; + } + else if (i >= 60) + { // 60 - 77 $10 increments to $250, except 63 = $125 and 69 = $175 + dollars += 10; + } + else if (i >= 50) + { // 50 - 59 USD $5 increments + dollars += 5; + } + else + { // 1 - 49 USD $1 increments + dollars++; + } + } + } + } + } + + private static string CreateApplePriceTierString(int tier, int roundedDollars) + { + return string.Format("Tier {0} - USD {1:0.00}", tier, (float)roundedDollars - 0.01f); + } + } } diff --git a/Editor/AppleXMLProductCatalogExporter.cs b/Editor/AppleXMLProductCatalogExporter.cs index 632b844..2500a37 100644 --- a/Editor/AppleXMLProductCatalogExporter.cs +++ b/Editor/AppleXMLProductCatalogExporter.cs @@ -17,47 +17,61 @@ internal class AppleXMLProductCatalogExporter : ProductCatalogEditor.IProductCat { internal static string kMandatoryExportFolder; - internal List kFilesToCopy = new List (); + internal List kFilesToCopy = new List(); private const string kNewLine = "\n"; - public string DisplayName { - get { + public string DisplayName + { + get + { return "Apple XML Delivery"; } } - public string DefaultFileName { - get { + public string DefaultFileName + { + get + { return "metadata"; } } - public string FileExtension { - get { + public string FileExtension + { + get + { return "xml"; } } - public string StoreName { - get { + public string StoreName + { + get + { return AppleAppStore.Name; } } - public string MandatoryExportFolder { - get { + public string MandatoryExportFolder + { + get + { return kMandatoryExportFolder; } } - public List FilesToCopy { - get { + public List FilesToCopy + { + get + { return kFilesToCopy; } } - public bool SaveCompletePackage { - get { + public bool SaveCompletePackage + { + get + { return true; } } @@ -85,8 +99,9 @@ public string Export(ProductCatalog catalog) XElement inAppPurchases = new XElement(ns + "in_app_purchases"); softwareMetadata.Add(inAppPurchases); - foreach (var item in catalog.allProducts) { - XElement inAppPurchase = new XElement(ns + "in_app_purchase", + foreach (var item in catalog.allProducts) + { + XElement inAppPurchase = new XElement(ns + "in_app_purchase", new XElement(ns + "product_id", item.GetStoreID(AppleAppStore.Name) ?? item.id), new XElement(ns + "reference_name", item.id), new XElement(ns + "type", ProductTypeString(item))); @@ -101,10 +116,12 @@ public string Export(ProductCatalog catalog) inAppPurchase.Add(locales); // Variable number of localizations, not every product will specify a localization for every language // so some of the these descriptions may be missing, in which case we just skip it. - foreach (var loc in localesToExport) { + foreach (var loc in localesToExport) + { LocalizedProductDescription desc = item.defaultDescription.googleLocale == loc ? item.defaultDescription : item.GetDescription(loc); - if (desc != null) { - XElement locale = new XElement(ns + "locale", + if (desc != null) + { + XElement locale = new XElement(ns + "locale", new XAttribute("name", LocaleToAppleString(loc)), new XElement(ns + "title", desc.Title), new XElement(ns + "description", desc.Description)); @@ -116,7 +133,8 @@ public string Export(ProductCatalog catalog) inAppPurchase.Add(reviewScreenshot); reviewScreenshot.Add(new XElement(ns + "file_name", Path.GetFileName(item.screenshotPath))); FileInfo fileInfo = new FileInfo(item.screenshotPath); - if (fileInfo.Exists) { + if (fileInfo.Exists) + { reviewScreenshot.Add(new XElement(ns + "size", fileInfo.Length)); reviewScreenshot.Add(new XElement(ns + "checksum", GetMD5Hash(fileInfo))); } @@ -132,14 +150,17 @@ public ExporterValidationResults Validate(ProductCatalog catalog) var results = new ExporterValidationResults(); // Warn if exporting an empty catalog - if (catalog.allProducts.Count == 0) { + if (catalog.allProducts.Count == 0) + { results.warnings.Add("Catalog is empty"); } // Check for duplicate IDs var usedIds = new HashSet(); - foreach (var product in catalog.allProducts) { - if (usedIds.Contains(product.id)) { + foreach (var product in catalog.allProducts) + { + if (usedIds.Contains(product.id)) + { results.errors.Add("More than one product uses the ID \"" + product.id + "\""); } usedIds.Add(product.id); @@ -147,10 +168,13 @@ public ExporterValidationResults Validate(ProductCatalog catalog) // Check for duplicate store IDs var usedStoreIds = new HashSet(); - foreach (var product in catalog.allProducts) { + foreach (var product in catalog.allProducts) + { var storeID = product.GetStoreID(AppleAppStore.Name); - if (!string.IsNullOrEmpty(storeID)) { - if (usedStoreIds.Contains(storeID)) { + if (!string.IsNullOrEmpty(storeID)) + { + if (usedStoreIds.Contains(storeID)) + { results.errors.Add("More than one product uses the Apple store ID \"" + storeID + "\""); } usedIds.Add(product.id); @@ -160,23 +184,29 @@ public ExporterValidationResults Validate(ProductCatalog catalog) // Check for duplicate runtime IDs -- this conflict could occur if a product has a base ID that is the // same as another product's store-specific ID var runtimeIDs = new HashSet(); - foreach (var product in catalog.allProducts) { + foreach (var product in catalog.allProducts) + { var runtimeID = product.GetStoreID(AppleAppStore.Name) ?? product.id; - if (runtimeIDs.Contains(runtimeID)) { + if (runtimeIDs.Contains(runtimeID)) + { results.errors.Add("More than one product is identified by the ID \"" + runtimeID + "\""); } runtimeIDs.Add(runtimeID); } // Check SKU - if (string.IsNullOrEmpty(catalog.appleSKU)) { + if (string.IsNullOrEmpty(catalog.appleSKU)) + { results.fieldErrors["appleSKU"] = "Apple SKU is required. Find this in iTunesConnect."; - } else { + } + else + { kMandatoryExportFolder = catalog.appleSKU + ".itmsp"; } // Check Team ID - if (string.IsNullOrEmpty(catalog.appleTeamID)) { + if (string.IsNullOrEmpty(catalog.appleTeamID)) + { results.fieldErrors["appleTeamID"] = "Apple Team ID is required. Find this on https://developer.apple.com."; } @@ -188,24 +218,30 @@ public ExporterValidationResults Validate(ProductCatalogItem item) var results = new ExporterValidationResults(); // Check for missing IDs - if (string.IsNullOrEmpty(item.id)) { + if (string.IsNullOrEmpty(item.id)) + { results.fieldErrors["id"] = "ID is required"; } // Check for missing title - if (string.IsNullOrEmpty(item.defaultDescription.Title)) { + if (string.IsNullOrEmpty(item.defaultDescription.Title)) + { results.fieldErrors["defaultDescription.Title"] = "Title is required"; } // Check for missing description - if (string.IsNullOrEmpty(item.defaultDescription.Description)) { + if (string.IsNullOrEmpty(item.defaultDescription.Description)) + { results.fieldErrors["defaultDescription.Description"] = "Description is required"; } // Check for screenshot - if (string.IsNullOrEmpty(item.screenshotPath)) { + if (string.IsNullOrEmpty(item.screenshotPath)) + { results.fieldErrors["screenshotPath"] = "Screenshot is required"; - } else { + } + else + { kFilesToCopy.Add(item.screenshotPath); } @@ -216,11 +252,13 @@ private HashSet GetLocalesToExport(ProductCatalog catalog) { var locs = new HashSet(); - foreach (var item in catalog.allProducts) { + foreach (var item in catalog.allProducts) + { if (item.defaultDescription.googleLocale.SupportedOnApple()) locs.Add(item.defaultDescription.googleLocale); - foreach (var desc in item.translatedDescriptions) { + foreach (var desc in item.translatedDescriptions) + { if (desc.googleLocale.SupportedOnApple()) locs.Add(desc.googleLocale); } @@ -231,13 +269,14 @@ private HashSet GetLocalesToExport(ProductCatalog catalog) private static string ProductTypeString(ProductCatalogItem item) { - switch (item.type) { - case ProductType.Consumable: - return "consumable"; - case ProductType.NonConsumable: - return "non-consumable"; - case ProductType.Subscription: - return "subscription"; + switch (item.type) + { + case ProductType.Consumable: + return "consumable"; + case ProductType.NonConsumable: + return "non-consumable"; + case ProductType.Subscription: + return "subscription"; } return string.Empty; @@ -245,7 +284,8 @@ private static string ProductTypeString(ProductCatalogItem item) private static string LocaleToAppleString(TranslationLocale loc) { - switch (loc) { + switch (loc) + { // Apple uses Hans and Hant, rather than Cn and TW case TranslationLocale.zh_CN: return "zh-Hans"; @@ -290,7 +330,7 @@ public static string GetMD5Hash(FileInfo fileInfo) // and create a string. StringBuilder stringBuilder = new StringBuilder(); - // Loop through each byte of the hashed data + // Loop through each byte of the hashed data // and format each one as a hexadecimal string. for (int i = 0; i < data.Length; i++) { diff --git a/Editor/AssemblyInfo.cs b/Editor/AssemblyInfo.cs index f893aa1..f389311 100644 --- a/Editor/AssemblyInfo.cs +++ b/Editor/AssemblyInfo.cs @@ -1,8 +1,7 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("UnityEditor.Purchasing.EditorTests")] -[assembly:InternalsVisibleTo("Unity.IntegrationTests")] -[assembly:InternalsVisibleTo("Unity.RuntimeTests")] -[assembly:InternalsVisibleTo("DynamicProxyGenAssembly2")] - +[assembly: InternalsVisibleTo("UnityEditor.Purchasing.EditorTests")] +[assembly: InternalsVisibleTo("Unity.IntegrationTests")] +[assembly: InternalsVisibleTo("Unity.RuntimeTests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/Editor/BuildTargetGroupExtensions.cs b/Editor/BuildTargetGroupExtensions.cs index 4e442e5..482c344 100644 --- a/Editor/BuildTargetGroupExtensions.cs +++ b/Editor/BuildTargetGroupExtensions.cs @@ -24,30 +24,30 @@ internal static ReadOnlyCollection ToAppStores(this BuildTargetGroup v switch (value) { case BuildTargetGroup.Android: - { - storesArray = ToAndroidAppStores(value); - break; - } + { + storesArray = ToAndroidAppStores(value); + break; + } case BuildTargetGroup.iOS: case BuildTargetGroup.tvOS: - storesArray = new[] {AppStore.AppleAppStore}; + storesArray = new[] { AppStore.AppleAppStore }; break; case BuildTargetGroup.WSA: - storesArray = new[] {AppStore.WinRT}; + storesArray = new[] { AppStore.WinRT }; break; case BuildTargetGroup.Standalone: if (Application.platform == RuntimePlatform.OSXEditor) { - storesArray = new[] {AppStore.MacAppStore}; + storesArray = new[] { AppStore.MacAppStore }; break; } goto default; default: - storesArray = new[] {AppStore.fake}; + storesArray = new[] { AppStore.fake }; break; } @@ -77,22 +77,22 @@ internal static string ToPlatformDisplayName(this BuildTargetGroup value) switch (value) { case BuildTargetGroup.iOS: - { - // TRICKY: Prefer an "iOS" string on BuildTarget, to avoid the unwanted "BuildTargetGroup.iPhone" - return BuildTarget.iOS.ToString(); - } + { + // TRICKY: Prefer an "iOS" string on BuildTarget, to avoid the unwanted "BuildTargetGroup.iPhone" + return BuildTarget.iOS.ToString(); + } case BuildTargetGroup.Standalone: - { - switch (EditorUserBuildSettings.activeBuildTarget) { - case BuildTarget.StandaloneOSX: - return "macOS"; - case BuildTarget.StandaloneWindows: - return "Windows"; - default: - return BuildTargetGroup.Standalone.ToString(); + switch (EditorUserBuildSettings.activeBuildTarget) + { + case BuildTarget.StandaloneOSX: + return "macOS"; + case BuildTarget.StandaloneWindows: + return "Windows"; + default: + return BuildTargetGroup.Standalone.ToString(); + } } - } default: return value.ToString(); } diff --git a/Editor/GooglePlayProductCatalogExporter.cs b/Editor/GooglePlayProductCatalogExporter.cs index 7686954..ee5ad99 100644 --- a/Editor/GooglePlayProductCatalogExporter.cs +++ b/Editor/GooglePlayProductCatalogExporter.cs @@ -7,277 +7,329 @@ namespace UnityEditor.Purchasing { /// - /// Exports a ProductCatalog to the CSV format expected by Google Play's batch import tools. - /// - internal class GooglePlayProductCatalogExporter : ProductCatalogEditor.IProductCatalogExporter - { - public string DisplayName { - get { - return "Google Play CSV"; - } - } - - public string DefaultFileName { - get { - return "GooglePlayProductCatalog"; - } - } - - public string FileExtension { - get { - return "csv"; - } - } - - public string StoreName { - get { - return GooglePlay.Name; - } - } - - public string MandatoryExportFolder { - get { - return null; - } - } - - public List FilesToCopy { - get { + /// Exports a ProductCatalog to the CSV format expected by Google Play's batch import tools. + /// + internal class GooglePlayProductCatalogExporter : ProductCatalogEditor.IProductCatalogExporter + { + public string DisplayName + { + get + { + return "Google Play CSV"; + } + } + + public string DefaultFileName + { + get + { + return "GooglePlayProductCatalog"; + } + } + + public string FileExtension + { + get + { + return "csv"; + } + } + + public string StoreName + { + get + { + return GooglePlay.Name; + } + } + + public string MandatoryExportFolder + { + get + { return null; } } - public bool SaveCompletePackage { - get { + public List FilesToCopy + { + get + { + return null; + } + } + + public bool SaveCompletePackage + { + get + { return false; } } - public string Export(ProductCatalog catalog) - { - var fileContents = new StringBuilder(); - var values = new string[8]; - - // Write column headers - values[0] = "Product ID"; - values[1] = "Published State"; - values[2] = "Purchase Type"; - values[3] = "Auto Translate"; - values[4] = "Locale; Title; Description"; - values[5] = "Auto Fill Prices"; - values[6] = "Price"; - values[7] = "Pricing Template ID"; - fileContents.Append(string.Join(kComma, values)); - fileContents.Append("\n"); - - foreach (var product in catalog.allProducts) { - if (string.IsNullOrEmpty(product.GetStoreID(GooglePlay.Name))) { - values[0] = CSVEscape(product.id); - } else { - values[0] = CSVEscape(product.GetStoreID(GooglePlay.Name)); - } - - values[1] = "published"; - values[2] = ProductTypeString(product.type); - - values[3] = kFalse; - values[4] = PackTitlesAndDescriptions(product); - - if (string.IsNullOrEmpty(product.pricingTemplateID)) { + public string Export(ProductCatalog catalog) + { + var fileContents = new StringBuilder(); + var values = new string[8]; + + // Write column headers + values[0] = "Product ID"; + values[1] = "Published State"; + values[2] = "Purchase Type"; + values[3] = "Auto Translate"; + values[4] = "Locale; Title; Description"; + values[5] = "Auto Fill Prices"; + values[6] = "Price"; + values[7] = "Pricing Template ID"; + fileContents.Append(string.Join(kComma, values)); + fileContents.Append("\n"); + + foreach (var product in catalog.allProducts) + { + if (string.IsNullOrEmpty(product.GetStoreID(GooglePlay.Name))) + { + values[0] = CSVEscape(product.id); + } + else + { + values[0] = CSVEscape(product.GetStoreID(GooglePlay.Name)); + } + + values[1] = "published"; + values[2] = ProductTypeString(product.type); + + values[3] = kFalse; + values[4] = PackTitlesAndDescriptions(product); + + if (string.IsNullOrEmpty(product.pricingTemplateID)) + { values[5] = kTrue; - values[6] = PackPrice(product); - values[7] = string.Empty; - } else { + values[6] = PackPrice(product); + values[7] = string.Empty; + } + else + { values[5] = kFalse; - values[6] = string.Empty; - values[7] = product.pricingTemplateID; - } - - fileContents.Append(string.Join(kComma, values)); - fileContents.Append("\n"); - } - - return fileContents.ToString(); - } - - public ExporterValidationResults Validate(ProductCatalog catalog) - { - var results = new ExporterValidationResults(); - - // Warn if exporting an empty catalog - if (catalog.allProducts.Count == 0) { - results.warnings.Add("Catalog is empty"); - } - - // Check for duplicate IDs - var usedIDs = new HashSet(); - foreach (var product in catalog.allProducts) { - if (usedIDs.Contains(product.id)) { - results.errors.Add("More than one product uses the ID \"" + product.id + "\""); - } - usedIDs.Add(product.id); - } - - // Check for duplicate store IDs - var usedStoreIDs = new HashSet(); - foreach (var product in catalog.allProducts) { - var storeID = product.GetStoreID(GooglePlay.Name); - if (!string.IsNullOrEmpty(storeID)) { - if (usedStoreIDs.Contains(storeID)) { - results.errors.Add("More than one product uses the Google Play store ID \"" + storeID + "\""); - } - usedStoreIDs.Add(product.id); - } - } - - // Check for duplicate runtime IDs -- this conflict could occur if a product has a base ID that is the - // same as another product's store-specific ID - var runtimeIDs = new HashSet(); - foreach (var product in catalog.allProducts) { - var runtimeID = product.GetStoreID(GooglePlay.Name); - if (string.IsNullOrEmpty(runtimeID)) { - runtimeID = product.id; - } - - if (runtimeIDs.Contains(runtimeID)) { - results.errors.Add("More than one product is identified by the ID \"" + runtimeID + "\""); - } - runtimeIDs.Add(runtimeID); - } - - return results; - } - - public ExporterValidationResults Validate(ProductCatalogItem item) - { - var results = new ExporterValidationResults(); - - // Check for missing IDs - if (string.IsNullOrEmpty(item.id)) { - results.errors.Add("ID is required"); - } - - // A product ID must start with a lowercase letter or a number and must be composed - // of only lowercase letters (a-z), numbers (0-9), underscores (_), and periods (.) - string actualID = item.GetStoreID(GooglePlay.Name) ?? item.id; - string field = (actualID == item.GetStoreID(GooglePlay.Name)) ? "storeID." + GooglePlay.Name : "id"; - if (Char.IsNumber(actualID[0]) || (Char.IsLower(actualID[0]) && Char.IsLetter(actualID[0]))) { - foreach (char c in actualID) { - if (c != '_' && c != '.' && !Char.IsNumber(c) && !(Char.IsLetter(c) && Char.IsLower(c))) { - results.fieldErrors[field] = "Product ID \"" + actualID + "\" must contain only lowercase letters, numbers, underscores, and periods"; - } - } - } else { - results.fieldErrors[field] = "Product ID \"" + actualID + "\" must start with a lowercase letter or a number"; - } - - ValidateDescription(item.defaultDescription, ref results, "defaultDescription"); - foreach (var desc in item.translatedDescriptions) { - ValidateDescription(desc, ref results); - } - - // Check for missing price information - if (string.IsNullOrEmpty(item.pricingTemplateID) && item.googlePrice.value == 0) { - results.fieldErrors["googlePrice"] = "Items must have either a price or a pricing template ID"; - } - - return results; - } - - private void ValidateDescription(LocalizedProductDescription desc, ref ExporterValidationResults results, string fieldPrefix = null) - { - if (fieldPrefix == null) { - fieldPrefix = "translatedDescriptions." + desc.googleLocale.ToString(); - } - - // Check for missing title - if (string.IsNullOrEmpty(desc.Title)) { - results.fieldErrors[fieldPrefix + ".Title"] = "Title is required (" + desc.googleLocale.ToString() + ")"; - } else { - if (desc.Title.Length > 55) { // Titles can be up to 55 characters in length - results.fieldErrors[fieldPrefix + ".Title"] = "Title must not be longer than 55 characters (" + desc.googleLocale.ToString() + ")"; - } else if (desc.Title.Length > 25) { // Titles should be no longer than 25 characters - results.warnings.Add("Title should not be longer than 25 characters (" + desc.googleLocale.ToString() + ")"); - } - } - - // Check for missing description - if (string.IsNullOrEmpty(desc.Description)) { - results.fieldErrors[fieldPrefix + ".Description"] = "Description is required (" + desc.googleLocale.ToString() + ")"; - } else { - if (desc.Description.Length > 80) { // Descriptions can be up to 80 characters in length - results.fieldErrors[fieldPrefix + ".Description"] = "Description must not be longer than 80 characters (" + desc.googleLocale.ToString() + ")"; - } - } - } - - private const string kTrue = "true"; - private const string kFalse = "false"; - private const string kComma = ","; - private const string kSemicolon = ";"; - private const string kBackslash = "\\"; - private const string kQuote = "\""; - private const string kEscapedQuote = "\"\""; - private static char[] kCSVCharactersToQuote = { ',', '"', '\n' }; - - private static string CSVEscape(string s) - { - if (s == null) - return s; - - if (s.Contains(kQuote)) { - s = s.Replace(kQuote, kEscapedQuote); - } - - if (s.IndexOfAny(kCSVCharactersToQuote) > -1) { - s = kQuote + s + kQuote; - } - - return s; - } - - private static string SSVEscape(string s) - { - if (s == null) - return s; - - s.Replace(kBackslash, kBackslash + kBackslash); - s.Replace(kSemicolon, kBackslash + kSemicolon); - return s; - } - - private static string ProductTypeString(ProductType type) - { - return "managed_by_android"; - } - - private static string PackTitlesAndDescriptions(ProductCatalogItem product) - { - var values = new List(); - - values.Add(product.defaultDescription.googleLocale.ToString()); - values.Add(SSVEscape(product.defaultDescription.Title)); - values.Add(SSVEscape(product.defaultDescription.Description)); - - foreach (var desc in product.translatedDescriptions) { - values.Add(desc.googleLocale.ToString()); - values.Add(SSVEscape(desc.Title)); - values.Add(SSVEscape(desc.Description)); - } - - return CSVEscape(string.Join(kSemicolon, values.ToArray())); - } - - private const int kPriceMicroUnitMultiplier = 1000000; - private static string PackPrice(ProductCatalogItem product) - { - return CSVEscape(Convert.ToInt32(product.googlePrice.value * kPriceMicroUnitMultiplier).ToString()); - } - - public ProductCatalog NormalizeToType(ProductCatalog catalog) - { - return catalog; - } - } + values[6] = string.Empty; + values[7] = product.pricingTemplateID; + } + + fileContents.Append(string.Join(kComma, values)); + fileContents.Append("\n"); + } + + return fileContents.ToString(); + } + + public ExporterValidationResults Validate(ProductCatalog catalog) + { + var results = new ExporterValidationResults(); + + // Warn if exporting an empty catalog + if (catalog.allProducts.Count == 0) + { + results.warnings.Add("Catalog is empty"); + } + + // Check for duplicate IDs + var usedIDs = new HashSet(); + foreach (var product in catalog.allProducts) + { + if (usedIDs.Contains(product.id)) + { + results.errors.Add("More than one product uses the ID \"" + product.id + "\""); + } + usedIDs.Add(product.id); + } + + // Check for duplicate store IDs + var usedStoreIDs = new HashSet(); + foreach (var product in catalog.allProducts) + { + var storeID = product.GetStoreID(GooglePlay.Name); + if (!string.IsNullOrEmpty(storeID)) + { + if (usedStoreIDs.Contains(storeID)) + { + results.errors.Add("More than one product uses the Google Play store ID \"" + storeID + "\""); + } + usedStoreIDs.Add(product.id); + } + } + + // Check for duplicate runtime IDs -- this conflict could occur if a product has a base ID that is the + // same as another product's store-specific ID + var runtimeIDs = new HashSet(); + foreach (var product in catalog.allProducts) + { + var runtimeID = product.GetStoreID(GooglePlay.Name); + if (string.IsNullOrEmpty(runtimeID)) + { + runtimeID = product.id; + } + + if (runtimeIDs.Contains(runtimeID)) + { + results.errors.Add("More than one product is identified by the ID \"" + runtimeID + "\""); + } + runtimeIDs.Add(runtimeID); + } + + return results; + } + + public ExporterValidationResults Validate(ProductCatalogItem item) + { + var results = new ExporterValidationResults(); + + // Check for missing IDs + if (string.IsNullOrEmpty(item.id)) + { + results.errors.Add("ID is required"); + } + + // A product ID must start with a lowercase letter or a number and must be composed + // of only lowercase letters (a-z), numbers (0-9), underscores (_), and periods (.) + string actualID = item.GetStoreID(GooglePlay.Name) ?? item.id; + string field = (actualID == item.GetStoreID(GooglePlay.Name)) ? "storeID." + GooglePlay.Name : "id"; + if (Char.IsNumber(actualID[0]) || (Char.IsLower(actualID[0]) && Char.IsLetter(actualID[0]))) + { + foreach (char c in actualID) + { + if (c != '_' && c != '.' && !Char.IsNumber(c) && !(Char.IsLetter(c) && Char.IsLower(c))) + { + results.fieldErrors[field] = "Product ID \"" + actualID + "\" must contain only lowercase letters, numbers, underscores, and periods"; + } + } + } + else + { + results.fieldErrors[field] = "Product ID \"" + actualID + "\" must start with a lowercase letter or a number"; + } + + ValidateDescription(item.defaultDescription, ref results, "defaultDescription"); + foreach (var desc in item.translatedDescriptions) + { + ValidateDescription(desc, ref results); + } + + // Check for missing price information + if (string.IsNullOrEmpty(item.pricingTemplateID) && item.googlePrice.value == 0) + { + results.fieldErrors["googlePrice"] = "Items must have either a price or a pricing template ID"; + } + + return results; + } + + private void ValidateDescription(LocalizedProductDescription desc, ref ExporterValidationResults results, string fieldPrefix = null) + { + if (fieldPrefix == null) + { + fieldPrefix = "translatedDescriptions." + desc.googleLocale.ToString(); + } + + // Check for missing title + if (string.IsNullOrEmpty(desc.Title)) + { + results.fieldErrors[fieldPrefix + ".Title"] = "Title is required (" + desc.googleLocale.ToString() + ")"; + } + else + { + if (desc.Title.Length > 55) + { // Titles can be up to 55 characters in length + results.fieldErrors[fieldPrefix + ".Title"] = "Title must not be longer than 55 characters (" + desc.googleLocale.ToString() + ")"; + } + else if (desc.Title.Length > 25) + { // Titles should be no longer than 25 characters + results.warnings.Add("Title should not be longer than 25 characters (" + desc.googleLocale.ToString() + ")"); + } + } + + // Check for missing description + if (string.IsNullOrEmpty(desc.Description)) + { + results.fieldErrors[fieldPrefix + ".Description"] = "Description is required (" + desc.googleLocale.ToString() + ")"; + } + else + { + if (desc.Description.Length > 80) + { // Descriptions can be up to 80 characters in length + results.fieldErrors[fieldPrefix + ".Description"] = "Description must not be longer than 80 characters (" + desc.googleLocale.ToString() + ")"; + } + } + } + + private const string kTrue = "true"; + private const string kFalse = "false"; + private const string kComma = ","; + private const string kSemicolon = ";"; + private const string kBackslash = "\\"; + private const string kQuote = "\""; + private const string kEscapedQuote = "\"\""; + private static char[] kCSVCharactersToQuote = { ',', '"', '\n' }; + + private static string CSVEscape(string s) + { + if (s == null) + return s; + + if (s.Contains(kQuote)) + { + s = s.Replace(kQuote, kEscapedQuote); + } + + if (s.IndexOfAny(kCSVCharactersToQuote) > -1) + { + s = kQuote + s + kQuote; + } + + return s; + } + + private static string SSVEscape(string s) + { + if (s == null) + return s; + + s.Replace(kBackslash, kBackslash + kBackslash); + s.Replace(kSemicolon, kBackslash + kSemicolon); + return s; + } + + private static string ProductTypeString(ProductType type) + { + return "managed_by_android"; + } + + private static string PackTitlesAndDescriptions(ProductCatalogItem product) + { + var values = new List(); + + values.Add(product.defaultDescription.googleLocale.ToString()); + values.Add(SSVEscape(product.defaultDescription.Title)); + values.Add(SSVEscape(product.defaultDescription.Description)); + + foreach (var desc in product.translatedDescriptions) + { + values.Add(desc.googleLocale.ToString()); + values.Add(SSVEscape(desc.Title)); + values.Add(SSVEscape(desc.Description)); + } + + return CSVEscape(string.Join(kSemicolon, values.ToArray())); + } + + private const int kPriceMicroUnitMultiplier = 1000000; + private static string PackPrice(ProductCatalogItem product) + { + return CSVEscape(Convert.ToInt32(product.googlePrice.value * kPriceMicroUnitMultiplier).ToString()); + } + + public ProductCatalog NormalizeToType(ProductCatalog catalog) + { + return catalog; + } + } } diff --git a/Editor/IAPButtonEditor.cs b/Editor/IAPButtonEditor.cs index 7146c56..5c29801 100644 --- a/Editor/IAPButtonEditor.cs +++ b/Editor/IAPButtonEditor.cs @@ -8,61 +8,67 @@ namespace UnityEditor.Purchasing /// /// Customer Editor class for the IAPButton. This class handle how the IAPButton should represent itself in the UnityEditor. /// - [CustomEditor(typeof(IAPButton))] - [CanEditMultipleObjects] - public class IAPButtonEditor : Editor - { - private static readonly string[] excludedFields = new string[] { "m_Script" }; - private static readonly string[] restoreButtonExcludedFields = new string[] { "m_Script", "consumePurchase", "onPurchaseComplete", "onPurchaseFailed", "titleText", "descriptionText", "priceText" }; - private const string kNoProduct = ""; + [CustomEditor(typeof(IAPButton))] + [CanEditMultipleObjects] + public class IAPButtonEditor : Editor + { + private static readonly string[] excludedFields = new string[] { "m_Script" }; + private static readonly string[] restoreButtonExcludedFields = new string[] { "m_Script", "consumePurchase", "onPurchaseComplete", "onPurchaseFailed", "titleText", "descriptionText", "priceText" }; + private const string kNoProduct = ""; - private List m_ValidIDs = new List(); - private SerializedProperty m_ProductIDProperty; + private List m_ValidIDs = new List(); + private SerializedProperty m_ProductIDProperty; - /// - /// Event trigger when IAPButton is enabled in the scene. - /// + /// + /// Event trigger when IAPButton is enabled in the scene. + /// public void OnEnable() - { - m_ProductIDProperty = serializedObject.FindProperty("productId"); - } + { + m_ProductIDProperty = serializedObject.FindProperty("productId"); + } - /// - /// Event trigger when trying to draw the IAPButton in the inspector. - /// + /// + /// Event trigger when trying to draw the IAPButton in the inspector. + /// public override void OnInspectorGUI() - { - IAPButton button = (IAPButton)target; + { + IAPButton button = (IAPButton)target; - serializedObject.Update(); + serializedObject.Update(); - if (button.buttonType == IAPButton.ButtonType.Purchase) { - EditorGUILayout.LabelField(new GUIContent("Product ID:", "Select a product from the IAP catalog.")); + if (button.buttonType == IAPButton.ButtonType.Purchase) + { + EditorGUILayout.LabelField(new GUIContent("Product ID:", "Select a product from the IAP catalog.")); - var catalog = ProductCatalog.LoadDefaultCatalog(); + var catalog = ProductCatalog.LoadDefaultCatalog(); - m_ValidIDs.Clear(); - m_ValidIDs.Add(kNoProduct); - foreach (var product in catalog.allProducts) { - m_ValidIDs.Add(product.id); - } + m_ValidIDs.Clear(); + m_ValidIDs.Add(kNoProduct); + foreach (var product in catalog.allProducts) + { + m_ValidIDs.Add(product.id); + } - int currentIndex = string.IsNullOrEmpty(button.productId) ? 0 : m_ValidIDs.IndexOf(button.productId); - int newIndex = EditorGUILayout.Popup(currentIndex, m_ValidIDs.ToArray()); - if (newIndex > 0 && newIndex < m_ValidIDs.Count) { - m_ProductIDProperty.stringValue = m_ValidIDs[newIndex]; - } else { - m_ProductIDProperty.stringValue = string.Empty; - } + int currentIndex = string.IsNullOrEmpty(button.productId) ? 0 : m_ValidIDs.IndexOf(button.productId); + int newIndex = EditorGUILayout.Popup(currentIndex, m_ValidIDs.ToArray()); + if (newIndex > 0 && newIndex < m_ValidIDs.Count) + { + m_ProductIDProperty.stringValue = m_ValidIDs[newIndex]; + } + else + { + m_ProductIDProperty.stringValue = string.Empty; + } - if (GUILayout.Button("IAP Catalog...")) { - ProductCatalogEditor.ShowWindow(); - } - } + if (GUILayout.Button("IAP Catalog...")) + { + ProductCatalogEditor.ShowWindow(); + } + } - DrawPropertiesExcluding(serializedObject, button.buttonType == IAPButton.ButtonType.Restore ? restoreButtonExcludedFields : excludedFields); + DrawPropertiesExcluding(serializedObject, button.buttonType == IAPButton.ButtonType.Restore ? restoreButtonExcludedFields : excludedFields); - serializedObject.ApplyModifiedProperties(); - } - } + serializedObject.ApplyModifiedProperties(); + } + } } diff --git a/Editor/MenuItems/IapButtonMenu.cs b/Editor/MenuItems/IapButtonMenu.cs index 75ca7bb..092d0de 100644 --- a/Editor/MenuItems/IapButtonMenu.cs +++ b/Editor/MenuItems/IapButtonMenu.cs @@ -22,7 +22,7 @@ public static void GameObjectCreateUnityIAPButton() /// /// Add option to create a IAPButton from the Window/UnityIAP menu. /// - [MenuItem (IapMenuConsts.MenuItemRoot + "/Create IAP Button", false, 100)] + [MenuItem(IapMenuConsts.MenuItemRoot + "/Create IAP Button", false, 100)] public static void CreateUnityIAPButton() { CreateUnityIAPButtonInternal(); diff --git a/Editor/MenuItems/IapListenerMenu.cs b/Editor/MenuItems/IapListenerMenu.cs index b7d47af..94f1b83 100644 --- a/Editor/MenuItems/IapListenerMenu.cs +++ b/Editor/MenuItems/IapListenerMenu.cs @@ -23,7 +23,7 @@ public static void GameObjectCreateUnityIAPListener() /// /// Add option to create a IAPListener from the Window/UnityIAP menu. /// - [MenuItem (IapMenuConsts.MenuItemRoot + "/Create IAP Listener", false, 100)] + [MenuItem(IapMenuConsts.MenuItemRoot + "/Create IAP Listener", false, 100)] public static void CreateUnityIAPListener() { CreateUnityIAPListenerInternal(); @@ -36,7 +36,8 @@ static void CreateUnityIAPListenerInternal() { GameObject listenerObject = CreateListenerObject(); - if (listenerObject) { + if (listenerObject) + { listenerObject.AddComponent(); listenerObject.name = "IAP Listener"; } diff --git a/Editor/Obfuscation/Service/ObfuscationGenerator.cs b/Editor/Obfuscation/Service/ObfuscationGenerator.cs index 4dca35d..25e6fa0 100644 --- a/Editor/Obfuscation/Service/ObfuscationGenerator.cs +++ b/Editor/Obfuscation/Service/ObfuscationGenerator.cs @@ -176,7 +176,7 @@ static void WriteObfuscatedClassAsAsset(string classnamePrefix, int key, int[] o { outfileText = outfileText.Replace(pair.Key, pair.Value); } - Directory.CreateDirectory (TangleFileConsts.k_OutputPath); + Directory.CreateDirectory(TangleFileConsts.k_OutputPath); File.WriteAllText(FullPathForTangleClass(classnamePrefix), outfileText); } } diff --git a/Editor/Obfuscation/Service/ObfuscationMigration.cs b/Editor/Obfuscation/Service/ObfuscationMigration.cs index db9b74e..d910711 100644 --- a/Editor/Obfuscation/Service/ObfuscationMigration.cs +++ b/Editor/Obfuscation/Service/ObfuscationMigration.cs @@ -32,7 +32,7 @@ internal static void MigrateObfuscations() private static void MoveObfuscatorFiles(string oldPath) { - Directory.CreateDirectory (TangleFileConsts.k_OutputPath); + Directory.CreateDirectory(TangleFileConsts.k_OutputPath); foreach (var prevFile in Directory.GetFiles(oldPath)) { @@ -40,7 +40,7 @@ private static void MoveObfuscatorFiles(string oldPath) } } - static void MoveObfuscatorFile(string file) + static void MoveObfuscatorFile(string file) { var fileName = Path.GetFileName(file); if (fileName.EndsWith(TangleFileConsts.k_ObfuscationClassSuffix)) diff --git a/Editor/Obfuscation/Service/TangleObfuscator.cs b/Editor/Obfuscation/Service/TangleObfuscator.cs index 2918247..cd45a03 100644 --- a/Editor/Obfuscation/Service/TangleObfuscator.cs +++ b/Editor/Obfuscation/Service/TangleObfuscator.cs @@ -12,7 +12,7 @@ public static class TangleObfuscator /// /// An Exception thrown when the tangle order array provided is invalid or shorter than the number of data slices made. /// - public class InvalidOrderArray : Exception {} + public class InvalidOrderArray : Exception { } /// /// Generates the obfucscation tangle data. @@ -30,18 +30,18 @@ public static byte[] Obfuscate(byte[] data, int[] order, out int rkey) if (order == null || order.Length < slices) { - throw new InvalidOrderArray(); - } + throw new InvalidOrderArray(); + } Array.Copy(data, res, data.Length); - for (int i = 0; i < slices - 1; i ++) + for (int i = 0; i < slices - 1; i++) { int j = rnd.Next(i, slices - 1); order[i] = j; int sliceSize = 20; // prob should be configurable var tmp = res.Skip(i * 20).Take(sliceSize).ToArray(); // tmp = res[i*20 .. slice] - Array.Copy(res, j * 20, res, i * 20, sliceSize); // res[i] = res[j*20 .. slice] - Array.Copy(tmp, 0, res, j * 20, sliceSize); // res[j] = tmp + Array.Copy(res, j * 20, res, i * 20, sliceSize); // res[i] = res[j*20 .. slice] + Array.Copy(tmp, 0, res, j * 20, sliceSize); // res[j] = tmp } order[slices - 1] = slices - 1; diff --git a/Editor/Obfuscation/UI/ObfuscatorWindow.cs b/Editor/Obfuscation/UI/ObfuscatorWindow.cs index 12e8bf9..9557b7f 100644 --- a/Editor/Obfuscation/UI/ObfuscatorWindow.cs +++ b/Editor/Obfuscation/UI/ObfuscatorWindow.cs @@ -54,12 +54,12 @@ internal class ObfuscatorWindow : RichEditorWindow /// string m_GooglePlayPublicKey = kPublicKeyPlaceholder; - #if !ENABLE_EDITOR_GAME_SERVICES +#if !ENABLE_EDITOR_GAME_SERVICES [MenuItem(IapMenuConsts.MenuItemRoot + "/Receipt Validation Obfuscator...", false, 200)] static void Init() { // Get existing open window or if none, make a new one: - ObfuscatorWindow window = (ObfuscatorWindow) EditorWindow.GetWindow(typeof(ObfuscatorWindow)); + ObfuscatorWindow window = (ObfuscatorWindow)EditorWindow.GetWindow(typeof(ObfuscatorWindow)); window.titleContent.text = kLabelTitle; window.minSize = new Vector2(340, 180); window.Show(); @@ -67,7 +67,7 @@ static void Init() GenericEditorMenuItemClickEventSenderHelpers.SendIapMenuOpenObfuscatorEvent(); GameServicesEventSenderHelpers.SendTopMenuReceiptValidationObfuscatorEvent(); } - #endif +#endif private ObfuscatorWindow() { diff --git a/Editor/ProductCatalogEditor.cs b/Editor/ProductCatalogEditor.cs index c875d54..3dbae6b 100644 --- a/Editor/ProductCatalogEditor.cs +++ b/Editor/ProductCatalogEditor.cs @@ -16,7 +16,8 @@ public class ProductCatalogEditor : EditorWindow { private const bool kValidateDebugLog = false; - private static string[] kStoreKeys = { + private static string[] kStoreKeys = + { AppleAppStore.Name, GooglePlay.Name, AmazonApps.Name, @@ -48,12 +49,10 @@ public static void ShowWindow() private ProductCatalog catalog; private Rect exportButtonRect; private ExporterValidationResults validation; - private bool enableCodelessAutoInitialization; private DateTime lastChanged; private bool dirty; - private readonly TimeSpan kSaveDelay = new TimeSpan (0, 0, 0, 0, 500); // 500 milliseconds - + private readonly TimeSpan kSaveDelay = new TimeSpan(0, 0, 0, 0, 500); // 500 milliseconds #region UDP Related Fields @@ -63,7 +62,7 @@ public static void ShowWindow() private static readonly Queue requestQueue = new Queue(); private static bool kIsPreparing = true; - private static TokenInfo kTokenInfo = new TokenInfo(); + private static TokenInfo kTokenInfo = new TokenInfo(); private static string kOrgId; private static object kAppStoreSettings; //UDP AppStoreSettings via Reflection private static IDictionary kIapItems = new Dictionary(); @@ -82,6 +81,7 @@ internal static void MigrateProductCatalog() try { FileInfo file = new FileInfo(ProductCatalog.kCatalogPath); + // This will create the new product catalog file location, if it already exists, // this will not do anything. file.Directory.Create(); @@ -97,7 +97,6 @@ internal static void MigrateProductCatalog() { AssetDatabase.MoveAsset(ProductCatalog.kPrevCatalogPath, ProductCatalog.kCatalogPath); } - } catch (Exception ex) { @@ -113,13 +112,7 @@ internal static bool DoesPrevCatalogPathExist() /// /// Property which gets the ProductCatalog instance which is being edited. /// - public ProductCatalog Catalog - { - get - { - return catalog; - } - } + public ProductCatalog Catalog => catalog; /// /// Sets the results of the validation of catalog items upon export. @@ -159,24 +152,22 @@ void OnEnable() productEditors.Add(new ProductCatalogItemEditor(product)); } - enableCodelessAutoInitialization = catalog.enableCodelessAutoInitialization; - if (s_udpAvailable && IsUdpInstalled()) { - kUdpErrorMsg = ""; - kTokenInfo = new TokenInfo(); + kUdpErrorMsg = ""; + kTokenInfo = new TokenInfo(); kValidLogin = true; - kValidConfig = true; - kIsPreparing = true; - kOrgId = null; - PrepareDeveloperInfo(); + kValidConfig = true; + kIsPreparing = true; + kOrgId = null; + PrepareDeveloperInfo(); } } - private static bool IsUdpInstalled() - { - return UnityPurchasingEditor.IsUdpUmpPackageInstalled() || UnityPurchasingEditor.IsUdpAssetStorePackageInstalled(); - } + private static bool IsUdpInstalled() + { + return UnityPurchasingEditor.IsUdpUmpPackageInstalled() || UnityPurchasingEditor.IsUdpAssetStorePackageInstalled(); + } private void OnDisable() { @@ -246,7 +237,7 @@ void OnGUI() bool catalogHasProducts = !catalog.IsEmpty(); if (catalogHasProducts) { - ShowAndProcessCodelessAutoInitToggleGui(); + ShowAndProcessCodelessAutoInitToggleGuis(); } EditorGUILayout.EndVertical(); @@ -270,7 +261,7 @@ void OnGUI() ProductCatalogExportWindow.kWidth, EditorGUIUtility.singleLineHeight); if (GUI.Button(exportButtonRect, - new GUIContent("App Store Export", "Export products for bulk import into app store tools."))) + new GUIContent("App Store Export", "Export products for bulk import into app store tools."))) { PopupWindow.Show(exportButtonRect, new ProductCatalogExportWindow(this)); } @@ -290,21 +281,59 @@ void OnGUI() } } - private void ShowAndProcessCodelessAutoInitToggleGui() + private void ShowAndProcessCodelessAutoInitToggleGuis() { EditorGUILayout.Space(); - var oldAutoInitializationToggle = catalog.enableCodelessAutoInitialization; - enableCodelessAutoInitialization = EditorGUILayout.Toggle( + + ShowAndProcessIapAutoInitToggleGui(); + if (catalog.enableCodelessAutoInitialization) + { + ShowAndProcessUgsAutoInitToggleGui(); + } + + EditorGUILayout.Space(); + } + + private void ShowAndProcessIapAutoInitToggleGui() + { + var newValue = EditorGUILayout.Toggle( new GUIContent("Automatically initialize UnityPurchasing (recommended)", "Automatically start Unity IAP if there are any products defined in this catalog. Uncheck this if you plan to initialize Unity IAP manually in your code."), - enableCodelessAutoInitialization); - catalog.enableCodelessAutoInitialization = enableCodelessAutoInitialization; + catalog.enableCodelessAutoInitialization); + + UpdateIapAutoInitValue(newValue); + } - if (oldAutoInitializationToggle != enableCodelessAutoInitialization) + private void UpdateIapAutoInitValue(bool newValue) + { + if (newValue != catalog.enableCodelessAutoInitialization) { - GenericEditorClickCheckboxEventSenderHelpers.SendCatalogAutoInitToggleEvent(enableCodelessAutoInitialization); + catalog.enableCodelessAutoInitialization = newValue; + + GenericEditorClickCheckboxEventSenderHelpers.SendCatalogAutoInitToggleEvent(newValue); + } + } + + private void ShowAndProcessUgsAutoInitToggleGui() + { + var newValue = EditorGUILayout.Toggle(new GUIContent( + "Automatically initialize Unity Gaming Services", + "This initializes Unity Gaming Services with the default `production` environment.\n" + + "This way of initializing Unity Gaming Services might not be compatible with all other services as they might require special initialization options.\n" + + "If the use of initialization options is needed, Unity Gaming Services should be initialized with the coded API."), + catalog.enableUnityGamingServicesAutoInitialization); + + UpdateUgsAutoInitValue(newValue); + } + + private void UpdateUgsAutoInitValue(bool newValue) + { + if (newValue != catalog.enableUnityGamingServicesAutoInitialization) + { + catalog.enableUnityGamingServicesAutoInitialization = newValue; + + GenericEditorClickCheckboxEventSenderHelpers.SendCatalogUgsAutoInitToggleEvent(newValue); } - EditorGUILayout.Space(); } string ShowEditTextFieldGuiAndGetValue(string fieldName, string label, string oldText) @@ -413,11 +442,13 @@ private static void BeginErrorBlock(ExporterValidationResults validation, string private static void EndErrorBlock(ExporterValidationResults validation, string fieldName) { - if (EditorGUI.EndChangeCheck() && validation != null) { + if (EditorGUI.EndChangeCheck() && validation != null) + { validation.fieldErrors.Remove(fieldName); } - if (validation != null && validation.fieldErrors.ContainsKey(fieldName)) { + if (validation != null && validation.fieldErrors.ContainsKey(fieldName)) + { var style = new GUIStyle(); style.normal.textColor = Color.red; EditorGUILayout.LabelField(validation.fieldErrors[fieldName], style); @@ -468,45 +499,47 @@ private void CheckApiUpdate() kUdpErrorMsg = ""; } } + // No error. else { if (resp.GetType() == typeof(TokenInfo)) { resp = JsonUtility.FromJson(downloadedRawJson); - kTokenInfo.access_token = ((TokenInfo) resp).access_token; - kTokenInfo.refresh_token = ((TokenInfo) resp).refresh_token; + kTokenInfo.access_token = ((TokenInfo)resp).access_token; + kTokenInfo.refresh_token = ((TokenInfo)resp).refresh_token; var newRequest = UdpSynchronizationApi.CreateGetOrgIdRequest(kTokenInfo.access_token, Application.cloudProjectId); - ReqStruct newReqStruct = new ReqStruct {request = newRequest, resp = new OrgIdResponse()}; + ReqStruct newReqStruct = new ReqStruct { request = newRequest, resp = new OrgIdResponse() }; requestQueue.Enqueue(newReqStruct); } + // Get orgId request else if (resp.GetType() == typeof(OrgIdResponse)) { resp = JsonUtility.FromJson(downloadedRawJson); - kOrgId = ((OrgIdResponse) resp).org_foreign_key; - - if (kAppStoreSettings != null) - { - var appSlug = AppStoreSettingsInterface.GetAppSlugField(); - - // Then, get all iap items - requestQueue.Enqueue(new ReqStruct - { - request = UdpSynchronizationApi.CreateSearchStoreItemRequest(kTokenInfo.access_token, kOrgId, (string)appSlug.GetValue(kAppStoreSettings)), - resp = new IapItemSearchResponse() - }); - } + kOrgId = ((OrgIdResponse)resp).org_foreign_key; + + if (kAppStoreSettings != null) + { + var appSlug = AppStoreSettingsInterface.GetAppSlugField(); + + // Then, get all iap items + requestQueue.Enqueue(new ReqStruct + { + request = UdpSynchronizationApi.CreateSearchStoreItemRequest(kTokenInfo.access_token, kOrgId, (string)appSlug.GetValue(kAppStoreSettings)), + resp = new IapItemSearchResponse() + }); + } } else if (resp.GetType() == typeof(IapItemSearchResponse)) { if (downloadedRawJson != null) { resp = JsonUtility.FromJson(downloadedRawJson); - foreach (var item in ((IapItemSearchResponse) resp).results) + foreach (var item in ((IapItemSearchResponse)resp).results) { kIapItems[item.slug] = item; } @@ -514,6 +547,7 @@ private void CheckApiUpdate() kIsPreparing = false; } + // Creating/Updating IAP item succeeds else if (resp.GetType() == typeof(IapItemResponse)) { @@ -523,10 +557,11 @@ private void CheckApiUpdate() { reqStruct.itemEditor.udpItemSyncing = false; kIapItems[reqStruct.iapItem.slug] = reqStruct.iapItem; - kIapItems[reqStruct.iapItem.slug].id = ((IapItemResponse) resp).id; + kIapItems[reqStruct.iapItem.slug].id = ((IapItemResponse)resp).id; } } } + Repaint(); } else @@ -559,7 +594,6 @@ void TryParseErrorAsJson(string downloadedRawJson, long responseCode) } } - /// /// Get userId, orgId of the developer. Make prepare for syncing /// @@ -570,7 +604,7 @@ void PrepareDeveloperInfo() if (udpAppStoreSettings != null) { var assetPathProp = AppStoreSettingsInterface.GetAssetPathField(); - var clientIDProp = AppStoreSettingsInterface.GetClientIDField(); + var clientIDProp = AppStoreSettingsInterface.GetClientIDField(); kAppStoreSettings = AssetDatabase.LoadAssetAtPath((string)assetPathProp.GetValue(null), udpAppStoreSettings); @@ -606,14 +640,14 @@ public void GetAuthCode(T response) { var authCodePropertyInfo = response.GetType().GetProperty("AuthCode"); var exceptionPropertyInfo = response.GetType().GetProperty("Exception"); - string authCode = (string) authCodePropertyInfo.GetValue(response, null); - Exception exception = (Exception) exceptionPropertyInfo.GetValue(response, null); + string authCode = (string)authCodePropertyInfo.GetValue(response, null); + Exception exception = (Exception)exceptionPropertyInfo.GetValue(response, null); if (authCode != null) { var request = UdpSynchronizationApi.CreateGetAccessTokenRequest(authCode); TokenInfo tokenInfoResp = new TokenInfo(); - ReqStruct reqStruct = new ReqStruct {request = request, resp = tokenInfoResp}; + ReqStruct reqStruct = new ReqStruct { request = request, resp = tokenInfoResp }; requestQueue.Enqueue(reqStruct); } else @@ -695,7 +729,7 @@ public void OnGUI() var box = EditorGUILayout.BeginVertical(); Rect rect = new Rect(box.xMax - EditorGUIUtility.singleLineHeight - 2, box.yMin, EditorGUIUtility.singleLineHeight + 2, EditorGUIUtility.singleLineHeight); - if (GUI.Button(rect, "x") && EditorUtility.DisplayDialog("Delete Product?", "Are you sure you want to delete this product?","Delete","Do Not Delete")) + if (GUI.Button(rect, "x") && EditorUtility.DisplayDialog("Delete Product?", "Are you sure you want to delete this product?", "Delete", "Do Not Delete")) { toRemove.Add(this); GenericEditorButtonClickEventSenderHelpers.SendCatalogRemoveProductEvent(); @@ -704,13 +738,15 @@ public void OnGUI() ShowValidationResultsGUI(validation); var productLabel = Item.id + (string.IsNullOrEmpty(Item.defaultDescription.Title) - ? string.Empty - : " - " + Item.defaultDescription.Title); + ? string.Empty + : " - " + Item.defaultDescription.Title); if (string.IsNullOrEmpty(productLabel) || Item.id.Trim().Length == 0) { productLabel = "Product ID is Empty"; - } else { + } + else + { idInvalid = false; } @@ -831,13 +867,16 @@ public void OnGUI() EditorGUILayout.Separator(); storeIDsVisible = CompatibleGUI.Foldout(storeIDsVisible, "Store ID Overrides", true, s); - if (storeIDsVisible) { + + if (storeIDsVisible) + { EditorGUI.indentLevel++; foreach (string storeKey in kStoreKeys) { var newStoreID = ShowEditTextFieldGuiWithValidationErrorBlockAndGetValue("storeID." + storeKey, storeKey, Item.GetStoreID(storeKey)); Item.SetStoreID(storeKey, newStoreID); } + EditorGUI.indentLevel--; } @@ -879,22 +918,24 @@ public void OnGUI() { EditorGUI.indentLevel++; - if (!string.IsNullOrEmpty(udpSyncErrorMsg)){ + if (!string.IsNullOrEmpty(udpSyncErrorMsg)) + { var errStyle = new GUIStyle(); errStyle.normal.textColor = Color.red; EditorGUILayout.LabelField(udpSyncErrorMsg, errStyle); } - var udpFieldsDisabled = kIsPreparing || udpItemSyncing || !kValidLogin || !kValidConfig; + var udpFieldsDisabled = kIsPreparing || udpItemSyncing || !kValidLogin || !kValidConfig; - //If everything appears ok, check UDP compatibility and warn user if there's a problem - //This should not stop the user from doing some UDP sync work, as there is no current blocker for those features. - if (!udpFieldsDisabled && string.IsNullOrEmpty(kUdpErrorMsg) && !UdpSynchronizationApi.CheckUdpCompatibility()) - { - kUdpErrorMsg = "Please update your UDP package. Transaction features will no longer work at runtime with your current UDP version"; - } + //If everything appears ok, check UDP compatibility and warn user if there's a problem + //This should not stop the user from doing some UDP sync work, as there is no current blocker for those features. + if (!udpFieldsDisabled && string.IsNullOrEmpty(kUdpErrorMsg) && !UdpSynchronizationApi.CheckUdpCompatibility()) + { + kUdpErrorMsg = "Please update your UDP package. Transaction features will no longer work at runtime with your current UDP version"; + } - if (!string.IsNullOrEmpty(kUdpErrorMsg)){ + if (!string.IsNullOrEmpty(kUdpErrorMsg)) + { var errStyle = new GUIStyle(); errStyle.normal.textColor = Color.red; EditorGUILayout.LabelField(kUdpErrorMsg, errStyle); @@ -936,11 +977,11 @@ public void OnGUI() price = Item.udpPrice.value.ToString() }); - if (kAppStoreSettings != null) - { - var appSlug = AppStoreSettingsInterface.GetAppSlugField(); - iapItem.masterItemSlug = (string)appSlug.GetValue(kAppStoreSettings); - } + if (kAppStoreSettings != null) + { + var appSlug = AppStoreSettingsInterface.GetAppSlugField(); + iapItem.masterItemSlug = (string)appSlug.GetValue(kAppStoreSettings); + } iapItem.ownerId = kOrgId; @@ -985,6 +1026,7 @@ public void OnGUI() EditorGUI.indentLevel--; } } + #endregion EditorGUI.indentLevel--; @@ -1008,7 +1050,7 @@ void ShowAndProcessProductIDBlockGui(Rect idRect) var style = new GUIStyle(); style.normal.textColor = Color.red; - var duplicateIDLabelRect = new Rect(idRect.xMax + 5, idRect.yMin, k_DuplicateIDFieldWidth,EditorGUIUtility.singleLineHeight); + var duplicateIDLabelRect = new Rect(idRect.xMax + 5, idRect.yMin, k_DuplicateIDFieldWidth, EditorGUIUtility.singleLineHeight); EditorGUI.LabelField(duplicateIDLabelRect, idDuplicate ? "ID is a duplicate" : string.Empty, style); EditorGUI.LabelField(duplicateIDLabelRect, idDuplicate && idInvalid && shouldBeMarked ? "ID is empty" : string.Empty, style); @@ -1024,7 +1066,7 @@ void ShowAndProcessProductTypeBlockGui(float width) var typeRect = EditorGUILayout.GetControlRect(true); typeRect.width = width; - Item.type = (ProductType) EditorGUI.EnumPopup(typeRect, "Type:", Item.type); + Item.type = (ProductType)EditorGUI.EnumPopup(typeRect, "Type:", Item.type); if (oldType != Item.type) { @@ -1038,7 +1080,7 @@ void ShowAndProcessProductTypeBlockGui(float width) void ShowAndProcessPayoutBlockGui(ProductCatalogPayout payout) { var oldType = payout.type; - payout.type = (ProductCatalogPayout.ProductCatalogPayoutType) EditorGUILayout.EnumPopup("Type", payout.type); + payout.type = (ProductCatalogPayout.ProductCatalogPayoutType)EditorGUILayout.EnumPopup("Type", payout.type); if (oldType != payout.type) { var typeName = Enum.GetName(typeof(ProductCatalogPayout.ProductCatalogPayoutType), payout.type); @@ -1047,7 +1089,7 @@ void ShowAndProcessPayoutBlockGui(ProductCatalogPayout payout) payout.subtype = TruncateString(ShowEditTextFieldGuiAndGetValue("payoutSubtype", "Subtype", payout.subtype), ProductCatalogPayout.MaxSubtypeLength); payout.quantity = ShowEditDoubleFieldGuiAndGetValue("payoutQuantity", "Quantity", payout.quantity); - payout.data = TruncateString(ShowEditTextFieldGuiAndGetValue("payoutData","Data", payout.data), ProductCatalogPayout.MaxDataLength); + payout.data = TruncateString(ShowEditTextFieldGuiAndGetValue("payoutData", "Data", payout.data), ProductCatalogPayout.MaxDataLength); } void ShowAndProcessGoogleConfigGui() @@ -1068,7 +1110,7 @@ void ShowAndProcessGoogleConfigGui() Item.googlePrice.value = 0; } - Item.pricingTemplateID = ShowEditTextFieldGuiAndGetValue("googlePriceTemplate","Pricing Template:", Item.pricingTemplateID); + Item.pricingTemplateID = ShowEditTextFieldGuiAndGetValue("googlePriceTemplate", "Pricing Template:", Item.pricingTemplateID); EndErrorBlock(validation, fieldName); } @@ -1107,10 +1149,8 @@ void ShowAndProcessAppleConfigGui() } EditorGUILayout.EndVertical(); - } - /// /// Sets the validation results upon export of this item. /// @@ -1162,7 +1202,8 @@ private bool DescriptionEditorGUI(LocalizedProductDescription description, bool var removeButtonWidth = EditorGUIUtility.singleLineHeight + 2; var rect = EditorGUILayout.GetControlRect(true); - if (showRemoveButton) { + if (showRemoveButton) + { rect.width -= removeButtonWidth; } @@ -1173,11 +1214,11 @@ private bool DescriptionEditorGUI(LocalizedProductDescription description, bool var removeButtonRect = new Rect(box.xMax - removeButtonWidth, box.yMin, removeButtonWidth, EditorGUIUtility.singleLineHeight); var remove = (showRemoveButton - && GUI.Button(removeButtonRect, "x") - && EditorUtility.DisplayDialog("Delete Translation?", - "Are you sure you want to delete this translation?", - "Delete", - "Do Not Delete")); + && GUI.Button(removeButtonRect, "x") + && EditorUtility.DisplayDialog("Delete Translation?", + "Are you sure you want to delete this translation?", + "Delete", + "Do Not Delete")); EditorGUILayout.EndVertical(); return remove; } @@ -1187,7 +1228,7 @@ void ShowAndProcessLocaleBlockGui(LocalizedProductDescription description, strin BeginErrorBlock(validation, fieldValidationPrefix + ".googleLocale"); var oldLocale = description.googleLocale; - description.googleLocale = (TranslationLocale)EditorGUI.Popup(rect, "Locale:", (int)description.googleLocale,LocaleExtensions.GetLabelsWithSupportedPlatforms()); + description.googleLocale = (TranslationLocale)EditorGUI.Popup(rect, "Locale:", (int)description.googleLocale, LocaleExtensions.GetLabelsWithSupportedPlatforms()); if (oldLocale != description.googleLocale) { var localeName = Enum.GetName(typeof(TranslationLocale), description.googleLocale); @@ -1230,11 +1271,11 @@ double ShowEditDoubleFieldGuiAndGetValue(string fieldName, string label, double return newAmount; } - private static string TruncateString (string s, int len) + private static string TruncateString(string s, int len) { - if (string.IsNullOrEmpty (s)) return s; + if (string.IsNullOrEmpty(s)) return s; if (len < 0) return string.Empty; - return s.Substring (0, Math.Min (s.Length, len)); + return s.Substring(0, Math.Min(s.Length, len)); } } @@ -1364,6 +1405,7 @@ private void Export(IProductCatalogExporter exporter) // Choose the location of the final directory var directoryPath = EditorUtility.SaveFolderPanel("Export to folder", "", ""); directoryPath = Path.Combine(directoryPath, exporter.MandatoryExportFolder); + // Replace any existing directory if (Directory.Exists(directoryPath)) { @@ -1371,6 +1413,7 @@ private void Export(IProductCatalogExporter exporter) } Directory.CreateDirectory(directoryPath); + // ExportHelper needs a single file, let it create the main file and save the auxilliary files. var mainFilePath = Path.Combine(directoryPath, string.Format("{0}.{1}", exporter.DefaultFileName, exporter.FileExtension)); @@ -1407,7 +1450,7 @@ private void Export(IProductCatalogExporter exporter) EditorUtility.DisplayDialog( "Exported Successfully", string.Format("Exported {0} to \"{2}\".\n\n" + - "Also saved copy into project at \"{1}\".", + "Also saved copy into project at \"{1}\".", exporter.DisplayName, nonInteractivePath, path), "OK"); } @@ -1526,7 +1569,6 @@ public bool Valid /// public List warnings = new List(); - /// /// The dictionary of field errors. /// diff --git a/Editor/RichEditorWindow.cs b/Editor/RichEditorWindow.cs index ec10979..795bf26 100644 --- a/Editor/RichEditorWindow.cs +++ b/Editor/RichEditorWindow.cs @@ -1,4 +1,4 @@ -using UnityEngine; +using UnityEngine; namespace UnityEditor.Purchasing { diff --git a/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs b/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs index be7a5ce..b574161 100644 --- a/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs +++ b/Editor/ServiceProjectSettings/Entity/Consts/UIResourceUtils.cs @@ -6,6 +6,7 @@ static class UIResourceUtils internal static readonly string labelUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/Label.uxml"; + internal static readonly string analyticsWarningUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/AnalyticsWarning.uxml"; internal static readonly string catalogUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/CatalogEditor.uxml"; internal static readonly string platformSupportUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/PlatformSupportVisual.uxml"; internal static readonly string googlePlayConfigUxmlPath = $"{SettingsUIConstants.packageUxmlRoot}/GooglePlayConfiguration.uxml"; diff --git a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs index c2eb8d2..1c438ad 100644 --- a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs +++ b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs @@ -13,6 +13,7 @@ protected BasePurchasingState(string stateName, SimpleStateMachine stateMa { m_UIBlocks = new List(); m_UIBlocks.Add(PlatformsAndStoresServiceSettingsBlock.CreateStateSpecificBlock(IsEnabled())); + m_UIBlocks.Add(new AnalyticsWarningSettingsBlock()); } internal List GetStateUI() diff --git a/Editor/ServiceProjectSettings/SimpleStateMachine.cs b/Editor/ServiceProjectSettings/SimpleStateMachine.cs index 4273260..4636459 100644 --- a/Editor/ServiceProjectSettings/SimpleStateMachine.cs +++ b/Editor/ServiceProjectSettings/SimpleStateMachine.cs @@ -243,7 +243,7 @@ State DoNothing(T simpleStateMachineEvent) /// It allows to do a common operation on the current state without having all the other states repeat this /// code within their transition actions. /// - public virtual void EnterState() {} + public virtual void EnterState() { } class ActionForEvent { diff --git a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml new file mode 100644 index 0000000..3b4463f --- /dev/null +++ b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml.meta b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml.meta new file mode 100644 index 0000000..e4c997d --- /dev/null +++ b/Editor/ServiceProjectSettings/UI/UXML/AnalyticsWarning.uxml.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e19dce4e8a4c4ef58fe19536b828cc63 +timeCreated: 1652387645 \ No newline at end of file diff --git a/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs b/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs new file mode 100644 index 0000000..572694e --- /dev/null +++ b/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs @@ -0,0 +1,22 @@ +using UnityEngine.UIElements; + +namespace UnityEditor.Purchasing +{ + internal class AnalyticsWarningSettingsBlock : IPurchasingSettingsUIBlock + { + VisualElement m_CatalogBlock; + + public VisualElement GetUIBlockElement() + { + m_CatalogBlock = SettingsUIUtils.CloneUIFromTemplate(UIResourceUtils.analyticsWarningUxmlPath); + SetupStyleSheets(); + return m_CatalogBlock; + } + + void SetupStyleSheets() + { + m_CatalogBlock.AddStyleSheetPath(UIResourceUtils.purchasingCommonUssPath); + m_CatalogBlock.AddStyleSheetPath(EditorGUIUtility.isProSkin ? UIResourceUtils.purchasingDarkUssPath : UIResourceUtils.purchasingLightUssPath); + } + } +} diff --git a/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs.meta b/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs.meta new file mode 100644 index 0000000..618f687 --- /dev/null +++ b/Editor/ServiceProjectSettings/UI/Views/AnalyticsWarningServiveSettingsBlock.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 472975f3563e410b9e580688252e8394 +timeCreated: 1652388481 \ No newline at end of file diff --git a/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs b/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs index 65711ca..598b789 100644 --- a/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs +++ b/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs @@ -87,7 +87,8 @@ void SetupButtonActions() googlePlayExternalLink.AddManipulator(clickable); } - m_ConfigurationBlock.Q(k_GooglePlayKeyEntry).RegisterValueChangedCallback(evt => { + m_ConfigurationBlock.Q(k_GooglePlayKeyEntry).RegisterValueChangedCallback(evt => + { m_GooglePlayDataRef.googlePlayKey = evt.newValue; }); } diff --git a/Editor/UdpInstaller.cs b/Editor/UdpInstaller.cs index aa7a1a4..e86d398 100644 --- a/Editor/UdpInstaller.cs +++ b/Editor/UdpInstaller.cs @@ -26,7 +26,7 @@ public class UdpInstaller internal static void PromptUdpInstallation() { - var window = (UdpInstallInstructionsWindow) EditorWindow.GetWindow(typeof(UdpInstallInstructionsWindow)); + var window = (UdpInstallInstructionsWindow)EditorWindow.GetWindow(typeof(UdpInstallInstructionsWindow)); #if UNITY_2019_3_OR_NEWER window.titleContent.text = k_PackManWindowTitle; #else diff --git a/Editor/UdpSynchronizationApi.cs b/Editor/UdpSynchronizationApi.cs index d975b14..eb84ccc 100644 --- a/Editor/UdpSynchronizationApi.cs +++ b/Editor/UdpSynchronizationApi.cs @@ -218,12 +218,12 @@ internal static bool CheckUdpCompatibility() return false; } - var udpVersion = BuildConfigInterface.GetVersion(); - int majorVersion = 0; - int.TryParse(udpVersion.Split('.')[0], out majorVersion); + var udpVersion = BuildConfigInterface.GetVersion(); + int majorVersion = 0; + int.TryParse(udpVersion.Split('.')[0], out majorVersion); - return majorVersion >= 2; - } + return majorVersion >= 2; + } // A very tricky way to deal with the json string, need to be improved // en-US and zh-CN will appear in the JSON and Unity JsonUtility cannot diff --git a/Editor/UnityPurchasingEditor.cs b/Editor/UnityPurchasingEditor.cs index dd35320..08a9ba6 100644 --- a/Editor/UnityPurchasingEditor.cs +++ b/Editor/UnityPurchasingEditor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using UnityEngine; @@ -8,7 +8,8 @@ using UnityEditor.PackageManager; using UnityEditor.PackageManager.Requests; -namespace UnityEditor.Purchasing { +namespace UnityEditor.Purchasing +{ /// /// Editor tools to set build-time configurations for app stores. @@ -231,7 +232,8 @@ internal static AppStore TryTargetAndroidStore(AppStore target) // below to process the private static void ConfigureProject(AppStore target) { - foreach (var mapping in StoreSpecificFiles) { + foreach (var mapping in StoreSpecificFiles) + { // All files enabled when store is determined at runtime. var enabled = target == AppStore.NotSpecified; // Otherwise this file must be needed on the target. @@ -240,14 +242,18 @@ private static void ConfigureProject(AppStore target) string path = string.Format("{0}/{1}", BinPath, mapping.Key); PluginImporter importer = ((PluginImporter)PluginImporter.GetAtPath(path)); - if (importer != null) { - importer.SetCompatibleWithPlatform (BuildTarget.Android, enabled); - } else { + if (importer != null) + { + importer.SetCompatibleWithPlatform(BuildTarget.Android, enabled); + } + else + { // Search for any occurrence of this file // Only fail if more than one found string[] paths = FindPaths(mapping.Key); - if (paths.Length == 1) { + if (paths.Length == 1) + { importer = ((PluginImporter)PluginImporter.GetAtPath(paths[0])); importer.SetCompatibleWithPlatform(BuildTarget.Android, enabled); } @@ -268,7 +274,7 @@ private static void ConfigureProject(AppStore target) enabled |= mapping.Value == target; var path = $"{UdpBinPath}/{mapping.Key}"; - PluginImporter importer = ((PluginImporter) PluginImporter.GetAtPath(path)); + PluginImporter importer = ((PluginImporter)PluginImporter.GetAtPath(path)); if (importer != null) { @@ -282,7 +288,7 @@ private static void ConfigureProject(AppStore target) if (paths.Length == 1) { - importer = ((PluginImporter) PluginImporter.GetAtPath(paths[0])); + importer = ((PluginImporter)PluginImporter.GetAtPath(paths[0])); importer.SetCompatibleWithPlatform(BuildTarget.Android, enabled); } } @@ -318,7 +324,7 @@ public static string[] FindPaths(string filename) private static void SaveConfig(AppStore enabled) { - var configToSave = new StoreConfiguration (enabled); + var configToSave = new StoreConfiguration(enabled); File.WriteAllText(ModePath, StoreConfiguration.Serialize(configToSave)); AssetDatabase.ImportAsset(ModePath); config = configToSave; @@ -340,16 +346,19 @@ internal static void OnPostProcessScene() { if (File.Exists(ModePath)) { - try { + try + { config = StoreConfiguration.Deserialize(File.ReadAllText(ModePath)); ConfigureProject(config.androidStore); - } catch (Exception e) { - #if ENABLE_EDITOR_GAME_SERVICES + } + catch (Exception e) + { +#if ENABLE_EDITOR_GAME_SERVICES Debug.LogError("Unity IAP unable to strip undesired Android stores from build, check file: " + ModePath); - #else +#else Debug.LogError("Unity IAP unable to strip undesired Android stores from build, use menu (e.g. " + SwitchStoreMenuItem + ") and check file: " + ModePath); - #endif +#endif Debug.LogError(e); } } diff --git a/Editor/WebRequest/CloudProjectWebRequest.cs b/Editor/WebRequest/CloudProjectWebRequest.cs index c2e47cc..9aee678 100644 --- a/Editor/WebRequest/CloudProjectWebRequest.cs +++ b/Editor/WebRequest/CloudProjectWebRequest.cs @@ -2,7 +2,7 @@ namespace UnityEditor.Purchasing { - class CloudProjectWebRequest: IWebRequestInternal + class CloudProjectWebRequest : IWebRequestInternal { const string k_AuthHeaderName = "AUTHORIZATION"; static readonly string k_AuthHeaderValue = $"Bearer {CloudProjectSettings.accessToken}"; diff --git a/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.h b/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.h index 0986ad8..fb40658 100644 --- a/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.h +++ b/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.h @@ -2,19 +2,20 @@ #import #import "LifeCycleListener.h" -@protocol UnityEarlyTransactionObserverDelegate +@protocol UnityEarlyTransactionObserverDelegate - (void)promotionalPurchaseAttempted:(SKPayment *)payment; @end -@interface UnityEarlyTransactionObserver : NSObject { +@interface UnityEarlyTransactionObserver : NSObject +{ NSMutableSet *m_QueuedPayments; } @property BOOL readyToReceiveTransactionUpdates; -// The delegate exists so that the observer can notify it of attempted promotional purchases. +// The delegate exists so that the observer can notify it of attempted promotional purchases. @property(nonatomic, weak) id delegate; + (UnityEarlyTransactionObserver*)defaultObserver; diff --git a/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.mm b/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.mm index 0a14760..35c322b 100644 --- a/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.mm +++ b/Plugins/UnityPurchasing/iOS/UnityEarlyTransactionObserver.mm @@ -1,7 +1,8 @@ #import "UnityEarlyTransactionObserver.h" #import "UnityPurchasing.h" -void Log(NSString *message) { +void Log(NSString *message) +{ NSLog(@"UnityIAP UnityEarlyTransactionObserver: %@\n", message); } @@ -9,49 +10,61 @@ @implementation UnityEarlyTransactionObserver static UnityEarlyTransactionObserver *s_Observer = nil; -+(void)load { - if (!s_Observer) { ++ (void)load +{ + if (!s_Observer) + { s_Observer = [[UnityEarlyTransactionObserver alloc] init]; Log(@"Created"); - + [s_Observer registerLifeCycleListener]; } } -+ (UnityEarlyTransactionObserver*)defaultObserver { ++ (UnityEarlyTransactionObserver*)defaultObserver +{ return s_Observer; } -- (void)registerLifeCycleListener { +- (void)registerLifeCycleListener +{ UnityRegisterLifeCycleListener(self); Log(@"Registered for lifecycle events"); } -- (void)didFinishLaunching:(NSNotification*)notification { +- (void)didFinishLaunching:(NSNotification*)notification +{ Log(@"Added to the payment queue"); - [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; + [[SKPaymentQueue defaultQueue] addTransactionObserver: self]; } -- (void)setDelegate:(id)delegate { +- (void)setDelegate:(id)delegate +{ _delegate = delegate; [self sendQueuedPaymentsToInterceptor]; } -- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product { +- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product +{ Log(@"Payment queue shouldAddStorePayment"); - if (self.readyToReceiveTransactionUpdates && !self.delegate) { + if (self.readyToReceiveTransactionUpdates && !self.delegate) + { return YES; - } else { - if (m_QueuedPayments == nil) { + } + else + { + if (m_QueuedPayments == nil) + { m_QueuedPayments = [[NSMutableSet alloc] init]; } // If there is a delegate and we have not seen this payment yet, it means we should intercept promotional purchases // and just return the payment to the delegate. // Do not try to process it now. - if (self.delegate && [m_QueuedPayments member:payment] == nil) { - [self.delegate promotionalPurchaseAttempted:payment]; + if (self.delegate && [m_QueuedPayments member: payment] == nil) + { + [self.delegate promotionalPurchaseAttempted: payment]; } - [m_QueuedPayments addObject:payment]; + [m_QueuedPayments addObject: payment]; return NO; } return YES; @@ -59,24 +72,31 @@ - (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *) - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {} -- (void)initiateQueuedPayments { +- (void)initiateQueuedPayments +{ Log(@"Request to initiate queued payments"); - if (m_QueuedPayments != nil) { + if (m_QueuedPayments != nil) + { Log(@"Initiating queued payments"); - for (SKPayment *payment in m_QueuedPayments) { - [[SKPaymentQueue defaultQueue] addPayment:payment]; + for (SKPayment *payment in m_QueuedPayments) + { + [[SKPaymentQueue defaultQueue] addPayment: payment]; } [m_QueuedPayments removeAllObjects]; } } -- (void)sendQueuedPaymentsToInterceptor { +- (void)sendQueuedPaymentsToInterceptor +{ Log(@"Request to send queued payments to interceptor"); - if (m_QueuedPayments != nil) { + if (m_QueuedPayments != nil) + { Log(@"Sending queued payments to interceptor"); - for (SKPayment *payment in m_QueuedPayments) { - if (self.delegate) { - [self.delegate promotionalPurchaseAttempted:payment]; + for (SKPayment *payment in m_QueuedPayments) + { + if (self.delegate) + { + [self.delegate promotionalPurchaseAttempted: payment]; } } } diff --git a/Plugins/UnityPurchasing/iOS/UnityPurchasing.h b/Plugins/UnityPurchasing/iOS/UnityPurchasing.h index b70f5e7..5aee472 100644 --- a/Plugins/UnityPurchasing/iOS/UnityPurchasing.h +++ b/Plugins/UnityPurchasing/iOS/UnityPurchasing.h @@ -13,14 +13,15 @@ typedef void (*UnityPurchasingCallback)(const char* subject, const char* payload @end -@interface ReceiptRefresher : NSObject +@interface ReceiptRefresher : NSObject @property (nonatomic, strong) void (^callback)(BOOL); @end -@interface UnityPurchasing : NSObject { +@interface UnityPurchasing : NSObject +{ UnityPurchasingCallback messageCallback; NSMutableDictionary* validProducts; NSSet* productIds; @@ -31,14 +32,14 @@ typedef void (*UnityPurchasingCallback)(const char* subject, const char* payload NSMutableDictionary *transactionReceipts; } -+ (NSArray*) deserializeProductDefs:(NSString*)json; -+ (ProductDefinition*) deserializeProductDef:(NSString*)json; -+ (NSString*) serializeProductMetadata:(NSArray*)products; ++ (NSArray*)deserializeProductDefs:(NSString*)json; ++ (ProductDefinition*)deserializeProductDef:(NSString*)json; ++ (NSString*)serializeProductMetadata:(NSArray*)products; --(void) restorePurchases; --(NSString*) getAppReceipt; --(NSString*) getTransactionReceiptForProductId:(NSString *)productId; --(void) addTransactionObserver; +- (void)restorePurchases; +- (NSString*)getAppReceipt; +- (NSString*)getTransactionReceiptForProductId:(NSString *)productId; +- (void)addTransactionObserver; @property (nonatomic, strong) ReceiptRefresher* receiptRefresher; @property (nonatomic, strong) SKReceiptRefreshRequest* refreshRequest; @property BOOL simulateAskToBuyEnabled; @@ -46,4 +47,3 @@ typedef void (*UnityPurchasingCallback)(const char* subject, const char* payload @property (nonatomic) BOOL interceptPromotionalPurchases; @end - diff --git a/Plugins/UnityPurchasing/iOS/UnityPurchasing.m b/Plugins/UnityPurchasing/iOS/UnityPurchasing.m index c656356..f30c661 100644 --- a/Plugins/UnityPurchasing/iOS/UnityPurchasing.m +++ b/Plugins/UnityPurchasing/iOS/UnityPurchasing.m @@ -15,28 +15,31 @@ @implementation ProductDefinition @end -void UnityPurchasingLog(NSString *format, ...) { +void UnityPurchasingLog(NSString *format, ...) +{ va_list args; va_start(args, format); - NSString *message = [[NSString alloc] initWithFormat:format arguments:args]; + NSString *message = [[NSString alloc] initWithFormat: format arguments: args]; va_end(args); NSLog(@"UnityIAP: %@", message); } - @implementation ReceiptRefresher --(id) initWithCallback:(void (^)(BOOL))callbackBlock { +- (id)initWithCallback:(void (^)(BOOL))callbackBlock +{ self.callback = callbackBlock; return [super init]; } --(void) requestDidFinish:(SKRequest *)request { +- (void)requestDidFinish:(SKRequest *)request +{ self.callback(true); } --(void) request:(SKRequest *)request didFailWithError:(NSError *)error { +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ self.callback(false); } @@ -57,19 +60,21 @@ @implementation UnityPurchasing // Track our accumulated delay. int delayInSeconds = 2; --(NSString*) getAppReceipt { - +- (NSString*)getAppReceipt +{ NSBundle* bundle = [NSBundle mainBundle]; - if ([bundle respondsToSelector:@selector(appStoreReceiptURL)]) { + if ([bundle respondsToSelector: @selector(appStoreReceiptURL)]) + { NSURL *receiptURL = [bundle appStoreReceiptURL]; - if ([[NSFileManager defaultManager] fileExistsAtPath:[receiptURL path]]) { - NSData *receipt = [NSData dataWithContentsOfURL:receiptURL]; + if ([[NSFileManager defaultManager] fileExistsAtPath: [receiptURL path]]) + { + NSData *receipt = [NSData dataWithContentsOfURL: receiptURL]; #if MAC_APPSTORE // The base64EncodedStringWithOptions method was only added in OSX 10.9. NSString* result = [receipt mgb64_base64EncodedString]; #else - NSString* result = [receipt base64EncodedStringWithOptions:0]; + NSString* result = [receipt base64EncodedStringWithOptions: 0]; #endif return result; @@ -80,61 +85,76 @@ -(NSString*) getAppReceipt { return @""; } --(NSString*) getTransactionReceiptForProductId:(NSString *)productId { +- (NSString*)getTransactionReceiptForProductId:(NSString *)productId +{ NSString *result = transactionReceipts[productId]; - if (!result) { + if (!result) + { UnityPurchasingLog(@"No Transaction Receipt found for product %@", productId); } - return result ?: @""; + return result ? : @""; } --(void) UnitySendMessage:(NSString*) subject payload:(NSString*) payload { +- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload +{ messageCallback(subject.UTF8String, payload.UTF8String, @"".UTF8String, @"".UTF8String); } --(void) UnitySendMessage:(NSString*) subject payload:(NSString*) payload receipt:(NSString*) receipt { +- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt +{ messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, @"".UTF8String); } --(void) UnitySendMessage:(NSString*) subject payload:(NSString*) payload receipt:(NSString*) receipt transactionId:(NSString*) transactionId { +- (void)UnitySendMessage:(NSString*)subject payload:(NSString*)payload receipt:(NSString*)receipt transactionId:(NSString*)transactionId +{ messageCallback(subject.UTF8String, payload.UTF8String, receipt.UTF8String, transactionId.UTF8String); } --(void) setCallback:(UnityPurchasingCallback)callback { +- (void)setCallback:(UnityPurchasingCallback)callback +{ messageCallback = callback; } #if !MAC_APPSTORE --(BOOL) isiOS6OrEarlier { +- (BOOL)isiOS6OrEarlier +{ float version = [[[UIDevice currentDevice] systemVersion] floatValue]; return version < 7; } + #endif // Retrieve a receipt for the transaction, which will either // be the old style transaction receipt on <= iOS 6, // or the App Receipt in OSX and iOS 7+. --(NSString*) selectReceipt:(SKPaymentTransaction*) transaction { +- (NSString*)selectReceipt:(SKPaymentTransaction*)transaction +{ #if MAC_APPSTORE return [self getAppReceipt]; #else - if ([self isiOS6OrEarlier]) { - if (nil == transaction) { + if ([self isiOS6OrEarlier]) + { + if (nil == transaction) + { return @""; } NSString* receipt; - receipt = [[NSString alloc] initWithData:transaction.transactionReceipt encoding: NSUTF8StringEncoding]; + receipt = [[NSString alloc] initWithData: transaction.transactionReceipt encoding: NSUTF8StringEncoding]; return receipt; - } else { + } + else + { return [self getAppReceipt]; } #endif } --(void) refreshReceipt { +- (void)refreshReceipt +{ #if !MAC_APPSTORE - if ([self isiOS6OrEarlier]) { + if ([self isiOS6OrEarlier]) + { UnityPurchasingLog(@"RefreshReceipt not supported on iOS < 7!"); return; } @@ -142,10 +162,13 @@ -(void) refreshReceipt { self.receiptRefresher = [[ReceiptRefresher alloc] initWithCallback:^(BOOL success) { UnityPurchasingLog(@"RefreshReceipt status %d", success); - if (success) { - [self UnitySendMessage:@"onAppReceiptRefreshed" payload:[self getAppReceipt]]; - } else { - [self UnitySendMessage:@"onAppReceiptRefreshFailed" payload:nil]; + if (success) + { + [self UnitySendMessage: @"onAppReceiptRefreshed" payload: [self getAppReceipt]]; + } + else + { + [self UnitySendMessage: @"onAppReceiptRefreshFailed" payload: nil]; } }]; self.refreshRequest = [[SKReceiptRefreshRequest alloc] init]; @@ -154,112 +177,129 @@ -(void) refreshReceipt { } // Handle a new or restored purchase transaction by informing Unity. -- (void)onTransactionSucceeded:(SKPaymentTransaction*)transaction { +- (void)onTransactionSucceeded:(SKPaymentTransaction*)transaction +{ NSString* transactionId = transaction.transactionIdentifier; // This should never happen according to Apple's docs, but it does! - if (nil == transactionId) { + if (nil == transactionId) + { // Make something up, allowing us to identifiy the transaction when finishing it. transactionId = [[NSUUID UUID] UUIDString]; UnityPurchasingLog(@"Missing transaction Identifier!"); } // This transaction was marked as finished, but was not cleared from the queue. Try to clear it now, then pass the error up the stack as a DuplicateTransaction - if ([finishedTransactions containsObject:transactionId]) { - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + if ([finishedTransactions containsObject: transactionId]) + { + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; UnityPurchasingLog(@"DuplicateTransaction error with product %@ and transactionId %@", transaction.payment.productIdentifier, transactionId); - [self onPurchaseFailed:transaction.payment.productIdentifier reason:@"DuplicateTransaction" errorCode:@"" errorDescription:@"Duplicate transaction occurred"]; + [self onPurchaseFailed: transaction.payment.productIdentifier reason: @"DuplicateTransaction" errorCode: @"" errorDescription: @"Duplicate transaction occurred"]; return; // EARLY RETURN } // Item was successfully purchased or restored. - if (nil == [pendingTransactions objectForKey:transactionId]) { - [pendingTransactions setObject:transaction forKey:transactionId]; + if (nil == [pendingTransactions objectForKey: transactionId]) + { + [pendingTransactions setObject: transaction forKey: transactionId]; } - [self UnitySendMessage:@"OnPurchaseSucceeded" payload:transaction.payment.productIdentifier receipt:[self selectReceipt:transaction] transactionId:transactionId]; + [self UnitySendMessage: @"OnPurchaseSucceeded" payload: transaction.payment.productIdentifier receipt: [self selectReceipt: transaction] transactionId: transactionId]; } // Called back by managed code when the tranaction has been logged. --(void) finishTransaction:(NSString *)transactionIdentifier { - SKPaymentTransaction* transaction = [pendingTransactions objectForKey:transactionIdentifier]; - if (nil != transaction) { +- (void)finishTransaction:(NSString *)transactionIdentifier +{ + SKPaymentTransaction* transaction = [pendingTransactions objectForKey: transactionIdentifier]; + if (nil != transaction) + { UnityPurchasingLog(@"Finishing transaction %@", transactionIdentifier); - [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; // If this fails (user not logged into the store?), transaction is already removed from pendingTransactions, so future calls to finishTransaction will not retry - [pendingTransactions removeObjectForKey:transactionIdentifier]; - [finishedTransactions addObject:transactionIdentifier]; - } else { + [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; // If this fails (user not logged into the store?), transaction is already removed from pendingTransactions, so future calls to finishTransaction will not retry + [pendingTransactions removeObjectForKey: transactionIdentifier]; + [finishedTransactions addObject: transactionIdentifier]; + } + else + { UnityPurchasingLog(@"Transaction %@ not pending, nothing to finish here", transactionIdentifier); } } // Request information about our products from Apple. --(void) requestProducts:(NSSet*)paramIds +- (void)requestProducts:(NSSet*)paramIds { productIds = paramIds; - UnityPurchasingLog(@"Requesting %lu products", (unsigned long) [productIds count]); + UnityPurchasingLog(@"Requesting %lu products", (unsigned long)[productIds count]); // Start an immediate poll. - [self initiateProductPoll:0]; + [self initiateProductPoll: 0]; } // Execute a product metadata retrieval request via GCD. --(void) initiateProductPoll:(int) delayInSeconds +- (void)initiateProductPoll:(int)delayInSeconds { dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC); dispatch_after(popTime, dispatch_get_main_queue(), ^(void) { UnityPurchasingLog(@"Requesting product data..."); - request = [[SKProductsRequest alloc] initWithProductIdentifiers:productIds]; + request = [[SKProductsRequest alloc] initWithProductIdentifiers: productIds]; request.delegate = self; [request start]; }); } // Called by managed code when a user requests a purchase. --(void) purchaseProduct:(ProductDefinition*)productDef +- (void)purchaseProduct:(ProductDefinition*)productDef { // Look up our corresponding product. - SKProduct* requestedProduct = [validProducts objectForKey:productDef.storeSpecificId]; + SKProduct* requestedProduct = [validProducts objectForKey: productDef.storeSpecificId]; - if (requestedProduct != nil) { + if (requestedProduct != nil) + { UnityPurchasingLog(@"PurchaseProduct: %@", requestedProduct.productIdentifier); - if ([SKPaymentQueue canMakePayments]) { - SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:requestedProduct]; + if ([SKPaymentQueue canMakePayments]) + { + SKMutablePayment *payment = [SKMutablePayment paymentWithProduct: requestedProduct]; // Modify payment request for testing ask-to-buy - if (_simulateAskToBuyEnabled) { + if (_simulateAskToBuyEnabled) + { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" - if ([payment respondsToSelector:@selector(setSimulatesAskToBuyInSandbox:)]) { + if ([payment respondsToSelector: @selector(setSimulatesAskToBuyInSandbox:)]) + { UnityPurchasingLog(@"Queueing payment request with simulatesAskToBuyInSandbox enabled"); - [payment performSelector:@selector(setSimulatesAskToBuyInSandbox:) withObject:@YES]; + [payment performSelector: @selector(setSimulatesAskToBuyInSandbox:) withObject: @YES]; //payment.simulatesAskToBuyInSandbox = YES; } #pragma clang diagnostic pop } // Modify payment request with "applicationUsername" for fraud detection - if (_applicationUsername != nil) { - if ([payment respondsToSelector:@selector(setApplicationUsername:)]) { + if (_applicationUsername != nil) + { + if ([payment respondsToSelector: @selector(setApplicationUsername:)]) + { UnityPurchasingLog(@"Setting applicationUsername to %@", _applicationUsername); - [payment performSelector:@selector(setApplicationUsername:) withObject:_applicationUsername]; + [payment performSelector: @selector(setApplicationUsername:) withObject: _applicationUsername]; //payment.applicationUsername = _applicationUsername; } } - [[SKPaymentQueue defaultQueue] addPayment:payment]; - } else { + [[SKPaymentQueue defaultQueue] addPayment: payment]; + } + else + { UnityPurchasingLog(@"PurchaseProduct: IAP Disabled"); - [self onPurchaseFailed:productDef.storeSpecificId reason:@"PurchasingUnavailable" errorCode:@"SKErrorPaymentNotAllowed" errorDescription:@"User is not authorized to make payments"]; + [self onPurchaseFailed: productDef.storeSpecificId reason: @"PurchasingUnavailable" errorCode: @"SKErrorPaymentNotAllowed" errorDescription: @"User is not authorized to make payments"]; } - - } else { - [self onPurchaseFailed:productDef.storeSpecificId reason:@"ItemUnavailable" errorCode:@"" errorDescription:@"Unity IAP could not find requested product"]; + } + else + { + [self onPurchaseFailed: productDef.storeSpecificId reason: @"ItemUnavailable" errorCode: @"" errorDescription: @"Unity IAP could not find requested product"]; } } // Initiate a request to Apple to restore previously made purchases. --(void) restorePurchases +- (void)restorePurchases { UnityPurchasingLog(@"RestorePurchase"); [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; @@ -268,7 +308,8 @@ -(void) restorePurchases // A transaction observer should be added at startup (by managed code) // and maintained for the life of the app, since transactions can // be delivered at any time. --(void) addTransactionObserver { +- (void)addTransactionObserver +{ SKPaymentQueue* defaultQueue = [SKPaymentQueue defaultQueue]; // Detect whether an existing transaction observer is in place. @@ -278,40 +319,50 @@ -(void) addTransactionObserver { BOOL processExistingTransactions = false; if (defaultQueue != nil && defaultQueue.transactions != nil) { - if ([[defaultQueue transactions] count] > 0) { + if ([[defaultQueue transactions] count] > 0) + { processExistingTransactions = true; } } - [defaultQueue addTransactionObserver:self]; - if (processExistingTransactions) { - [self paymentQueue:defaultQueue updatedTransactions:defaultQueue.transactions]; + [defaultQueue addTransactionObserver: self]; + if (processExistingTransactions) + { + [self paymentQueue: defaultQueue updatedTransactions: defaultQueue.transactions]; } #if !MAC_APPSTORE UnityEarlyTransactionObserver *observer = [UnityEarlyTransactionObserver defaultObserver]; - if (observer) { + if (observer) + { observer.readyToReceiveTransactionUpdates = YES; - if (self.interceptPromotionalPurchases) { + if (self.interceptPromotionalPurchases) + { observer.delegate = self; - } else { + } + else + { [observer initiateQueuedPayments]; } } #endif } -- (void)initiateQueuedEarlyTransactionObserverPayments { +- (void)initiateQueuedEarlyTransactionObserverPayments +{ #if !MAC_APPSTORE [[UnityEarlyTransactionObserver defaultObserver] initiateQueuedPayments]; #endif } -- (void)presentCodeRedemptionSheet { +- (void)presentCodeRedemptionSheet +{ #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 && TARGET_OS_TV == 0 && !MAC_APPSTORE - if (@available(iOS 14, *)) { + if (@available(iOS 14, *)) + { [[SKPaymentQueue defaultQueue] presentCodeRedemptionSheet]; - } else + } + else #endif { UnityPurchasingLog(@"Offer Code redemption is available on iOS and iPadOS 14 and later"); @@ -322,9 +373,10 @@ - (void)presentCodeRedemptionSheet { #pragma mark - #pragma mark UnityEarlyTransactionObserverDelegate Methods -- (void)promotionalPurchaseAttempted:(SKPayment *)payment { +- (void)promotionalPurchaseAttempted:(SKPayment *)payment +{ UnityPurchasingLog(@"Promotional purchase attempted"); - [self UnitySendMessage:@"onPromotionalPurchaseAttempted" payload:payment.productIdentifier]; + [self UnitySendMessage: @"onPromotionalPurchaseAttempted" payload: payment.productIdentifier]; } #endif @@ -333,50 +385,54 @@ - (void)promotionalPurchaseAttempted:(SKPayment *)payment { #pragma mark SKProductsRequestDelegate Methods // Store Kit returns a response from an SKProductsRequest. -- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { - - UnityPurchasingLog(@"Received %lu products", (unsigned long) [response.products count]); +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response +{ + UnityPurchasingLog(@"Received %lu products", (unsigned long)[response.products count]); // Add the retrieved products to our set of valid products. - NSDictionary* fetchedProducts = [NSDictionary dictionaryWithObjects:response.products forKeys:[response.products valueForKey:@"productIdentifier"]]; - [validProducts addEntriesFromDictionary:fetchedProducts]; + NSDictionary* fetchedProducts = [NSDictionary dictionaryWithObjects: response.products forKeys: [response.products valueForKey: @"productIdentifier"]]; + [validProducts addEntriesFromDictionary: fetchedProducts]; - NSString* productJSON = [UnityPurchasing serializeProductMetadata:response.products]; + NSString* productJSON = [UnityPurchasing serializeProductMetadata: response.products]; // Send the app receipt as a separate parameter to avoid JSON parsing a large string. - [self UnitySendMessage:@"OnProductsRetrieved" payload:productJSON receipt:[self selectReceipt:nil] ]; + [self UnitySendMessage: @"OnProductsRetrieved" payload: productJSON receipt: [self selectReceipt: nil]]; } - #pragma mark - #pragma mark SKPaymentTransactionObserver Methods // A product metadata retrieval request failed. // We handle it by retrying at an exponentially increasing interval. -- (void)request:(SKRequest *)request didFailWithError:(NSError *)error { +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ delayInSeconds = MIN(MAX_REQUEST_PRODUCT_RETRY_DELAY, 2 * delayInSeconds); UnityPurchasingLog(@"SKProductRequest::didFailWithError: %ld, %@. Unity Purchasing will retry in %i seconds", (long)error.code, error.description, delayInSeconds); - [self initiateProductPoll:delayInSeconds]; + [self initiateProductPoll: delayInSeconds]; } -- (void)requestDidFinish:(SKRequest *)req { +- (void)requestDidFinish:(SKRequest *)req +{ request = nil; } -- (void)onPurchaseFailed:(NSString*) productId reason:(NSString*)reason errorCode:(NSString*)errorCode errorDescription:(NSString*)errorDescription { +- (void)onPurchaseFailed:(NSString*)productId reason:(NSString*)reason errorCode:(NSString*)errorCode errorDescription:(NSString*)errorDescription +{ NSMutableDictionary* dic = [[NSMutableDictionary alloc] init]; - [dic setObject:productId forKey:@"productId"]; - [dic setObject:reason forKey:@"reason"]; - [dic setObject:errorCode forKey:@"storeSpecificErrorCode"]; - [dic setObject:errorDescription forKey:@"message"]; + [dic setObject: productId forKey: @"productId"]; + [dic setObject: reason forKey: @"reason"]; + [dic setObject: errorCode forKey: @"storeSpecificErrorCode"]; + [dic setObject: errorDescription forKey: @"message"]; - NSData* data = [NSJSONSerialization dataWithJSONObject:dic options:0 error:nil]; - NSString* result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSData* data = [NSJSONSerialization dataWithJSONObject: dic options: 0 error: nil]; + NSString* result = [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; - [self UnitySendMessage:@"OnPurchaseFailed" payload:result]; + [self UnitySendMessage: @"OnPurchaseFailed" payload: result]; } -- (NSString*)purchaseErrorCodeToReason:(NSInteger) errorCode { - switch (errorCode) { +- (NSString*)purchaseErrorCodeToReason:(NSInteger)errorCode +{ + switch (errorCode) + { case SKErrorPaymentCancelled: return @"UserCancelled"; case SKErrorPaymentInvalid: @@ -392,9 +448,9 @@ - (NSString*)purchaseErrorCodeToReason:(NSInteger) errorCode { - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions { UnityPurchasingLog(@"UpdatedTransactions"); - for(SKPaymentTransaction *transaction in transactions) { - - [self handleTransaction:transaction]; + for (SKPaymentTransaction *transaction in transactions) + { + [self handleTransaction: transaction]; } } @@ -405,10 +461,10 @@ - (void)paymentQueue:(SKPaymentQueue *)queue removedTransactions:(NSArray *)tran } // Called when SKPaymentQueue has finished sending restored transactions. -- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue { - +- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue +{ UnityPurchasingLog(@"PaymentQueueRestoreCompletedTransactionsFinished"); - [self UnitySendMessage:@"onTransactionsRestoredSuccess" payload:@""]; + [self UnitySendMessage: @"onTransactionsRestoredSuccess" payload: @""]; } // Called if an error occurred while restoring transactions. @@ -417,83 +473,87 @@ - (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedW UnityPurchasingLog(@"restoreCompletedTransactionsFailedWithError"); // Restore was cancelled or an error occurred, so notify user. - [self UnitySendMessage:@"onTransactionsRestoredFail" payload:error.localizedDescription]; + [self UnitySendMessage: @"onTransactionsRestoredFail" payload: error.localizedDescription]; } -- (void) handleTransaction:(SKPaymentTransaction *) transaction +- (void)handleTransaction:(SKPaymentTransaction *)transaction { - if (transaction.payment.productIdentifier == nil) { + if (transaction.payment.productIdentifier == nil) + { return; } - SKProduct* product = [validProducts objectForKey:transaction.payment.productIdentifier]; - - switch (transaction.transactionState) { + SKProduct* product = [validProducts objectForKey: transaction.payment.productIdentifier]; + switch (transaction.transactionState) + { case SKPaymentTransactionStatePurchasing: // Item is still in the process of being purchased break; case SKPaymentTransactionStatePurchased: - [self handleTransactionPurchased:transaction forProduct:product]; + [self handleTransactionPurchased: transaction forProduct: product]; break; case SKPaymentTransactionStateRestored: - [self handleTransactionRestored:transaction forProduct:product]; + [self handleTransactionRestored: transaction forProduct: product]; break; case SKPaymentTransactionStateDeferred: - [self handleTransactionDeferred:transaction forProduct:product]; + [self handleTransactionDeferred: transaction forProduct: product]; break; case SKPaymentTransactionStateFailed: - [self handleTransactionFailed:transaction]; + [self handleTransactionFailed: transaction]; break; } } -- (void) handleTransactionPurchased:(SKPaymentTransaction*) transaction forProduct:(SKProduct*) product +- (void)handleTransactionPurchased:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product { - #if MAC_APPSTORE // There is no transactionReceipt on Mac NSString* receipt = @""; #else // The transactionReceipt field is deprecated, but is being used here to validate Ask-To-Buy purchases - NSString* receipt = [transaction.transactionReceipt base64EncodedStringWithOptions:0]; + NSString* receipt = [transaction.transactionReceipt base64EncodedStringWithOptions: 0]; #endif transactionReceipts[transaction.payment.productIdentifier] = receipt; - if (product != nil) { - [self onTransactionSucceeded:transaction]; + if (product != nil) + { + [self onTransactionSucceeded: transaction]; } } -- (void) handleTransactionRestored:(SKPaymentTransaction*) transaction forProduct:(SKProduct*) product +- (void)handleTransactionRestored:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product { - if (product != nil) { - [self onTransactionSucceeded:transaction]; + if (product != nil) + { + [self onTransactionSucceeded: transaction]; } } -- (void) handleTransactionDeferred:(SKPaymentTransaction*) transaction forProduct:(SKProduct*) product +- (void)handleTransactionDeferred:(SKPaymentTransaction*)transaction forProduct:(SKProduct*)product { - if (product != nil) { + if (product != nil) + { UnityPurchasingLog(@"PurchaseDeferred"); - [self UnitySendMessage:@"onProductPurchaseDeferred" payload:transaction.payment.productIdentifier]; + [self UnitySendMessage: @"onProductPurchaseDeferred" payload: transaction.payment.productIdentifier]; } } -- (void) handleTransactionFailed:(SKPaymentTransaction*) transaction +- (void)handleTransactionFailed:(SKPaymentTransaction*)transaction { // Purchase was either cancelled by user or an error occurred. - NSString* errorCode = [NSString stringWithFormat:@"%ld",(long)transaction.error.code]; + NSString* errorCode = [NSString stringWithFormat: @"%ld", (long)transaction.error.code]; UnityPurchasingLog(@"PurchaseFailed: %@", errorCode); - NSString* reason = [self purchaseErrorCodeToReason:transaction.error.code]; + NSString* reason = [self purchaseErrorCodeToReason: transaction.error.code]; NSString* errorCodeString = [UnityPurchasing storeKitErrorCodeNames][@(transaction.error.code)]; - if (errorCodeString == nil) { + if (errorCodeString == nil) + { errorCodeString = @"SKErrorUnknown"; } - NSString* errorDescription = [NSString stringWithFormat:@"APPLE_%@", transaction.error.localizedDescription]; - [self onPurchaseFailed:transaction.payment.productIdentifier reason:reason errorCode:errorCodeString errorDescription:errorDescription]; + NSString* errorDescription = [NSString stringWithFormat: @"APPLE_%@", transaction.error.localizedDescription]; + [self onPurchaseFailed: transaction.payment.productIdentifier reason: reason errorCode: errorCodeString errorDescription: errorDescription]; // Finished transactions should be removed from the payment queue. [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; @@ -502,18 +562,22 @@ - (void) handleTransactionFailed:(SKPaymentTransaction*) transaction - (void)fetchStorePromotionOrder { #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 - if (@available(iOS 11.0, *)) { + if (@available(iOS 11.0, *)) + { [[SKProductStorePromotionController defaultController] fetchStorePromotionOrderWithCompletionHandler:^(NSArray * _Nonnull storePromotionOrder, NSError * _Nullable error) { - if (error) { + if (error) + { UnityPurchasingLog(@"Error in fetchStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]); - [self UnitySendMessage:@"onFetchStorePromotionOrderFailed" payload:nil]; - } else { - UnityPurchasingLog(@"Fetched %lu store-promotion ordered products", (unsigned long) [storePromotionOrder count]); + [self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil]; + } + else + { + UnityPurchasingLog(@"Fetched %lu store-promotion ordered products", (unsigned long)[storePromotionOrder count]); - NSString *productIdsJSON = [UnityPurchasing serializeProductIdList:storePromotionOrder]; + NSString *productIdsJSON = [UnityPurchasing serializeProductIdList: storePromotionOrder]; - [self UnitySendMessage:@"onFetchStorePromotionOrderSucceeded" payload:productIdsJSON]; + [self UnitySendMessage: @"onFetchStorePromotionOrderSucceeded" payload: productIdsJSON]; } }]; } @@ -521,7 +585,7 @@ - (void)fetchStorePromotionOrder #endif { UnityPurchasingLog(@"Fetch store promotion order is only available on iOS and tvOS 11 or later"); - [self UnitySendMessage:@"onFetchStorePromotionOrderFailed" payload:nil]; + [self UnitySendMessage: @"onFetchStorePromotionOrderFailed" payload: nil]; } } @@ -532,14 +596,15 @@ - (void)updateStorePromotionOrder:(NSArray*)productIds { NSMutableArray* products = [[NSMutableArray alloc] init]; - for (NSString* productId in productIds) { - SKProduct* product = [validProducts objectForKey:productId]; + for (NSString* productId in productIds) + { + SKProduct* product = [validProducts objectForKey: productId]; if (product) - [products addObject:product]; + [products addObject: product]; } SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController]; - [controller updateStorePromotionOrder:products completionHandler:^(NSError* error) { + [controller updateStorePromotionOrder: products completionHandler:^(NSError* error) { if (error) UnityPurchasingLog(@"Error in updateStorePromotionOrder: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]); }]; @@ -554,31 +619,36 @@ - (void)updateStorePromotionOrder:(NSArray*)productIds - (void)fetchStorePromotionVisibilityForProduct:(NSString*)productId { #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 - if (@available(iOS 11.0, macOS 11.0, tvOS 11.0, *)) { - SKProduct *product = [validProducts objectForKey:productId]; + if (@available(iOS 11.0, macOS 11.0, tvOS 11.0, *)) + { + SKProduct *product = [validProducts objectForKey: productId]; [[SKProductStorePromotionController defaultController] - fetchStorePromotionVisibilityForProduct:product completionHandler:^(SKProductStorePromotionVisibility storePromotionVisibility, NSError * _Nullable error) { - if (error) { - UnityPurchasingLog(@"Error in fetchStorePromotionVisibilityForProduct: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]); + fetchStorePromotionVisibilityForProduct: product completionHandler:^(SKProductStorePromotionVisibility storePromotionVisibility, NSError * _Nullable error) { + if (error) + { + UnityPurchasingLog(@"Error in fetchStorePromotionVisibilityForProduct: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]); - [self UnitySendMessage:@"onFetchStorePromotionVisibilityFailed" payload:nil]; - } else { - NSString *visibility = [UnityPurchasing getStringForStorePromotionVisibility: storePromotionVisibility]; + [self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil]; + } + else + { + NSString *visibility = [UnityPurchasing getStringForStorePromotionVisibility: storePromotionVisibility]; - UnityPurchasingLog(@"Fetched Store Promotion Visibility for %@", product.productIdentifier); + UnityPurchasingLog(@"Fetched Store Promotion Visibility for %@", product.productIdentifier); - NSString *payload = [UnityPurchasing serializeVisibilityResultForProduct:productId withVisiblity:visibility]; + NSString *payload = [UnityPurchasing serializeVisibilityResultForProduct: productId withVisiblity: visibility]; - [self UnitySendMessage:@"onFetchStorePromotionVisibilitySucceeded" payload:payload]; - } + [self UnitySendMessage: @"onFetchStorePromotionVisibilitySucceeded" payload: payload]; } - ]; - } else + } + ]; + } + else #endif { UnityPurchasingLog(@"Fetch store promotion visibility is only available on iOS, macOS and tvOS 11 or later"); - [self UnitySendMessage:@"onFetchStorePromotionVisibilityFailed" payload:nil]; + [self UnitySendMessage: @"onFetchStorePromotionVisibilityFailed" payload: nil]; } } @@ -588,20 +658,21 @@ - (void)updateStorePromotionVisibility:(NSString*)visibility forProduct:(NSStrin #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 if (@available(iOS 11_0, *)) { - SKProduct *product = [validProducts objectForKey:productId]; - if (!product) { + SKProduct *product = [validProducts objectForKey: productId]; + if (!product) + { UnityPurchasingLog(@"updateStorePromotionVisibility unable to find product %@", productId); return; } SKProductStorePromotionVisibility v = SKProductStorePromotionVisibilityDefault; - if ([visibility isEqualToString:@"Hide"]) + if ([visibility isEqualToString: @"Hide"]) v = SKProductStorePromotionVisibilityHide; - else if ([visibility isEqualToString:@"Show"]) + else if ([visibility isEqualToString: @"Show"]) v = SKProductStorePromotionVisibilityShow; SKProductStorePromotionController* controller = [SKProductStorePromotionController defaultController]; - [controller updateStorePromotionVisibility:v forProduct:product completionHandler:^(NSError* error) { + [controller updateStorePromotionVisibility: v forProduct: product completionHandler:^(NSError* error) { if (error) UnityPurchasingLog(@"Error in updateStorePromotionVisibility: %@ - %@ - %@", [error code], [error domain], [error localizedDescription]); }]; @@ -613,12 +684,13 @@ - (void)updateStorePromotionVisibility:(NSString*)visibility forProduct:(NSStrin } } - -- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product { +- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product +{ #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 - if (@available(iOS 11_0, *)) { + if (@available(iOS 11_0, *)) + { // Just defer to the early transaction observer. This should have no effect, just return whatever the observer returns. - return [[UnityEarlyTransactionObserver defaultObserver] paymentQueue:queue shouldAddStorePayment:payment forProduct:product]; + return [[UnityEarlyTransactionObserver defaultObserver] paymentQueue: queue shouldAddStorePayment: payment forProduct: product]; } #endif return YES; @@ -626,8 +698,10 @@ - (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *) #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 + (NSString*)getStringForStorePromotionVisibility:(SKProductStorePromotionVisibility)storePromotionVisibility -API_AVAILABLE(macos(11.0), ios(11.0), tvos(11.0)){ - switch(storePromotionVisibility) { + API_AVAILABLE(macos(11.0), ios(11.0), tvos(11.0)) +{ + switch (storePromotionVisibility) + { case SKProductStorePromotionVisibilityShow: return @"Show"; case SKProductStorePromotionVisibilityHide: @@ -640,217 +714,264 @@ + (NSString*)getStringForStorePromotionVisibility:(SKProductStorePromotionVisibi } #endif -+(ProductDefinition*) decodeProductDefinition:(NSDictionary*) hash ++ (ProductDefinition*)decodeProductDefinition:(NSDictionary*)hash { ProductDefinition* product = [[ProductDefinition alloc] init]; - product.id = [hash objectForKey:@"id"]; - product.storeSpecificId = [hash objectForKey:@"storeSpecificId"]; - product.type = [hash objectForKey:@"type"]; + product.id = [hash objectForKey: @"id"]; + product.storeSpecificId = [hash objectForKey: @"storeSpecificId"]; + product.type = [hash objectForKey: @"type"]; return product; } -+ (NSArray*) deserializeProductDefs:(NSString*)json ++ (NSArray*)deserializeProductDefs:(NSString*)json { - NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSArray* hashes = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding]; + NSArray* hashes = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil]; NSMutableArray* result = [[NSMutableArray alloc] init]; - for (NSDictionary* hash in hashes) { - [result addObject:[self decodeProductDefinition:hash]]; + for (NSDictionary* hash in hashes) + { + [result addObject: [self decodeProductDefinition: hash]]; } return result; } -+ (ProductDefinition*) deserializeProductDef:(NSString*)json ++ (ProductDefinition*)deserializeProductDef:(NSString*)json { - NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary* hash = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - return [self decodeProductDefinition:hash]; + NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding]; + NSDictionary* hash = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil]; + return [self decodeProductDefinition: hash]; } -+ (NSString*) serializeProductMetadata:(NSArray*)appleProducts ++ (NSString*)serializeProductMetadata:(NSArray*)appleProducts { NSMutableArray* hashes = [[NSMutableArray alloc] init]; - for (id product in appleProducts) { - if (NULL == [product productIdentifier]) { + for (id product in appleProducts) + { + if (NULL == [product productIdentifier]) + { UnityPurchasingLog(@"Product is missing an identifier!"); continue; } NSMutableDictionary* hash = [[NSMutableDictionary alloc] init]; - [hashes addObject:hash]; + [hashes addObject: hash]; - [hash setObject:[product productIdentifier] forKey:@"storeSpecificId"]; + [hash setObject: [product productIdentifier] forKey: @"storeSpecificId"]; NSMutableDictionary* metadata = [[NSMutableDictionary alloc] init]; - [hash setObject:metadata forKey:@"metadata"]; + [hash setObject: metadata forKey: @"metadata"]; - if (NULL != [product price]) { - [metadata setObject:[product price] forKey:@"localizedPrice"]; + if (NULL != [product price]) + { + [metadata setObject: [product price] forKey: @"localizedPrice"]; } - if (NULL != [product priceLocale]) { - NSString *currencyCode = [[product priceLocale] objectForKey:NSLocaleCurrencyCode]; + if (NULL != [product priceLocale]) + { + NSString *currencyCode = [[product priceLocale] objectForKey: NSLocaleCurrencyCode]; // NSLocaleCurrencyCode has been seen to return nil. Avoid crashing and report the issue to the log. E.g. https://developer.apple.com/forums/thread/119838 - if (currencyCode != nil) { - [metadata setObject:currencyCode forKey:@"isoCurrencyCode"]; - } else { + if (currencyCode != nil) + { + [metadata setObject: currencyCode forKey: @"isoCurrencyCode"]; + } + else + { UnityPurchasingLog(@"Error: unable to determine localized currency code for product {%@}, [SKProduct priceLocale] identifier {%@}. NSLocaleCurrencyCode {%@} is nil. Using ISO Unknown Currency code, instead: {%@}.", [product productIdentifier], [[product priceLocale] localeIdentifier], currencyCode, ISO_CURRENCY_CODE_UNKNOWN); - [metadata setObject:ISO_CURRENCY_CODE_UNKNOWN forKey:@"isoCurrencyCode"]; + [metadata setObject: ISO_CURRENCY_CODE_UNKNOWN forKey: @"isoCurrencyCode"]; } } #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 || __TV_OS_VERSION_MAX_ALLOWED >= 110000 || __MAC_OS_X_VERSION_MAX_ALLOWED >= 101300 - if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product introductoryPrice])) { - [metadata setObject:[[product introductoryPrice] price] forKey:@"introductoryPrice"]; - if (nil != [[product introductoryPrice] priceLocale]) { - NSString *currencyCode = [[[product introductoryPrice] priceLocale] objectForKey:NSLocaleCurrencyCode]; - [metadata setObject:currencyCode forKey:@"introductoryPriceLocale"]; - } else { - [metadata setObject:@"" forKey:@"introductoryPriceLocale"]; + if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product introductoryPrice])) + { + [metadata setObject: [[product introductoryPrice] price] forKey: @"introductoryPrice"]; + if (nil != [[product introductoryPrice] priceLocale]) + { + NSString *currencyCode = [[[product introductoryPrice] priceLocale] objectForKey: NSLocaleCurrencyCode]; + [metadata setObject: currencyCode forKey: @"introductoryPriceLocale"]; } - if (nil != [[product introductoryPrice] numberOfPeriods]) { - NSNumber *numberOfPeriods = [NSNumber numberWithInt:[[product introductoryPrice] numberOfPeriods]]; - [metadata setObject:numberOfPeriods forKey:@"introductoryPriceNumberOfPeriods"]; - } else { - [metadata setObject:@"" forKey:@"introductoryPriceNumberOfPeriods"]; + else + { + [metadata setObject: @"" forKey: @"introductoryPriceLocale"]; } - if (nil != [[product introductoryPrice] subscriptionPeriod]) { - if (nil != [[[product introductoryPrice] subscriptionPeriod] numberOfUnits]) { - NSNumber *numberOfUnits = [NSNumber numberWithInt:[[[product introductoryPrice] subscriptionPeriod] numberOfUnits]]; - [metadata setObject:numberOfUnits forKey:@"numberOfUnits"]; - } else { - [metadata setObject:@"" forKey:@"numberOfUnits"]; + if (nil != [[product introductoryPrice] numberOfPeriods]) + { + NSNumber *numberOfPeriods = [NSNumber numberWithInt: [[product introductoryPrice] numberOfPeriods]]; + [metadata setObject: numberOfPeriods forKey: @"introductoryPriceNumberOfPeriods"]; + } + else + { + [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"]; + } + if (nil != [[product introductoryPrice] subscriptionPeriod]) + { + if (nil != [[[product introductoryPrice] subscriptionPeriod] numberOfUnits]) + { + NSNumber *numberOfUnits = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] numberOfUnits]]; + [metadata setObject: numberOfUnits forKey: @"numberOfUnits"]; + } + else + { + [metadata setObject: @"" forKey: @"numberOfUnits"]; + } + if (nil != [[[product introductoryPrice] subscriptionPeriod] unit]) + { + NSNumber *unit = [NSNumber numberWithInt: [[[product introductoryPrice] subscriptionPeriod] unit]]; + [metadata setObject: unit forKey: @"unit"]; } - if (nil != [[[product introductoryPrice] subscriptionPeriod] unit]) { - NSNumber *unit = [NSNumber numberWithInt:[[[product introductoryPrice] subscriptionPeriod] unit]]; - [metadata setObject:unit forKey:@"unit"]; - } else { - [metadata setObject:@"" forKey:@"unit"]; + else + { + [metadata setObject: @"" forKey: @"unit"]; } - } else { - [metadata setObject:@"" forKey:@"numberOfUnits"]; - [metadata setObject:@"" forKey:@"unit"]; } - } else { - [metadata setObject:@"" forKey:@"introductoryPrice"]; - [metadata setObject:@"" forKey:@"introductoryPriceLocale"]; - [metadata setObject:@"" forKey:@"introductoryPriceNumberOfPeriods"]; - [metadata setObject:@"" forKey:@"numberOfUnits"]; - [metadata setObject:@"" forKey:@"unit"]; + else + { + [metadata setObject: @"" forKey: @"numberOfUnits"]; + [metadata setObject: @"" forKey: @"unit"]; + } + } + else + { + [metadata setObject: @"" forKey: @"introductoryPrice"]; + [metadata setObject: @"" forKey: @"introductoryPriceLocale"]; + [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"]; + [metadata setObject: @"" forKey: @"numberOfUnits"]; + [metadata setObject: @"" forKey: @"unit"]; } - if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product subscriptionPeriod])) { - if (nil != [[product subscriptionPeriod] numberOfUnits]) { - NSNumber *numberOfUnits = [NSNumber numberWithInt:[[product subscriptionPeriod] numberOfUnits]]; - [metadata setObject:numberOfUnits forKey:@"subscriptionNumberOfUnits"]; - } else { - [metadata setObject:@"" forKey:@"subscriptionNumberOfUnits"]; + if ((@available(iOS 11_2, macOS 10_13_2, tvOS 11_2, *)) && (nil != [product subscriptionPeriod])) + { + if (nil != [[product subscriptionPeriod] numberOfUnits]) + { + NSNumber *numberOfUnits = [NSNumber numberWithInt: [[product subscriptionPeriod] numberOfUnits]]; + [metadata setObject: numberOfUnits forKey: @"subscriptionNumberOfUnits"]; + } + else + { + [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"]; + } + if (nil != [[product subscriptionPeriod] unit]) + { + NSNumber *unit = [NSNumber numberWithInt: [[product subscriptionPeriod] unit]]; + [metadata setObject: unit forKey: @"subscriptionPeriodUnit"]; } - if (nil != [[product subscriptionPeriod] unit]) { - NSNumber *unit = [NSNumber numberWithInt:[[product subscriptionPeriod] unit]]; - [metadata setObject:unit forKey:@"subscriptionPeriodUnit"]; - } else { - [metadata setObject:@"" forKey:@"subscriptionPeriodUnit"]; + else + { + [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"]; } - } else { - [metadata setObject:@"" forKey:@"subscriptionNumberOfUnits"]; - [metadata setObject:@"" forKey:@"subscriptionPeriodUnit"]; + } + else + { + [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"]; + [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"]; } #else - [metadata setObject:@"" forKey:@"introductoryPrice"]; - [metadata setObject:@"" forKey:@"introductoryPriceLocale"]; - [metadata setObject:@"" forKey:@"introductoryPriceNumberOfPeriods"]; - [metadata setObject:@"" forKey:@"numberOfUnits"]; - [metadata setObject:@"" forKey:@"unit"]; - [metadata setObject:@"" forKey:@"subscriptionNumberOfUnits"]; - [metadata setObject:@"" forKey:@"subscriptionPeriodUnit"]; + [metadata setObject: @"" forKey: @"introductoryPrice"]; + [metadata setObject: @"" forKey: @"introductoryPriceLocale"]; + [metadata setObject: @"" forKey: @"introductoryPriceNumberOfPeriods"]; + [metadata setObject: @"" forKey: @"numberOfUnits"]; + [metadata setObject: @"" forKey: @"unit"]; + [metadata setObject: @"" forKey: @"subscriptionNumberOfUnits"]; + [metadata setObject: @"" forKey: @"subscriptionPeriodUnit"]; #endif NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; - [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; - [numberFormatter setLocale:[product priceLocale]]; - NSString *formattedString = [numberFormatter stringFromNumber:[product price]]; + [numberFormatter setFormatterBehavior: NSNumberFormatterBehavior10_4]; + [numberFormatter setNumberStyle: NSNumberFormatterCurrencyStyle]; + [numberFormatter setLocale: [product priceLocale]]; + NSString *formattedString = [numberFormatter stringFromNumber: [product price]]; - if (NULL == formattedString) { + if (NULL == formattedString) + { UnityPurchasingLog(@"Unable to format a localized price"); - [metadata setObject:@"" forKey:@"localizedPriceString"]; - } else { - [metadata setObject:formattedString forKey:@"localizedPriceString"]; + [metadata setObject: @"" forKey: @"localizedPriceString"]; + } + else + { + [metadata setObject: formattedString forKey: @"localizedPriceString"]; } - if (NULL == [product localizedTitle]) { + if (NULL == [product localizedTitle]) + { UnityPurchasingLog(@"No localized title for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]); - [metadata setObject:@"" forKey:@"localizedTitle"]; - } else { - [metadata setObject:[product localizedTitle] forKey:@"localizedTitle"]; + [metadata setObject: @"" forKey: @"localizedTitle"]; + } + else + { + [metadata setObject: [product localizedTitle] forKey: @"localizedTitle"]; } - if (NULL == [product localizedDescription]) { + if (NULL == [product localizedDescription]) + { UnityPurchasingLog(@"No localized description for: %@. Have your products been disapproved in itunes connect?", [product productIdentifier]); - [metadata setObject:@"" forKey:@"localizedDescription"]; - } else { - [metadata setObject:[product localizedDescription] forKey:@"localizedDescription"]; + [metadata setObject: @"" forKey: @"localizedDescription"]; + } + else + { + [metadata setObject: [product localizedDescription] forKey: @"localizedDescription"]; } } - NSData *data = [NSJSONSerialization dataWithJSONObject:hashes options:0 error:nil]; - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSData *data = [NSJSONSerialization dataWithJSONObject: hashes options: 0 error: nil]; + return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; } -+ (NSString*) serializeProductIdList:(NSArray *)products ++ (NSString*)serializeProductIdList:(NSArray *)products { - NSMutableArray *productIds = [NSMutableArray arrayWithCapacity:products.count]; - for (SKProduct *product in products) { - [productIds addObject:product.productIdentifier]; + NSMutableArray *productIds = [NSMutableArray arrayWithCapacity: products.count]; + for (SKProduct *product in products) + { + [productIds addObject: product.productIdentifier]; } - NSData *data = [NSJSONSerialization dataWithJSONObject:productIds options:0 error:nil]; - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSData *data = [NSJSONSerialization dataWithJSONObject: productIds options: 0 error: nil]; + return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; } -+ (NSArray*) deserializeProductIdList:(NSString*)json ++ (NSArray*)deserializeProductIdList:(NSString*)json { - NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - return [[dict objectForKey:@"products"] copy]; + NSData* data = [json dataUsingEncoding: NSUTF8StringEncoding]; + NSDictionary* dict = [NSJSONSerialization JSONObjectWithData: data options: 0 error: nil]; + return [[dict objectForKey: @"products"] copy]; } -+ (NSString*) serializeVisibilityResultForProduct:(NSString *)productId withVisiblity:(NSString *)visibility ++ (NSString*)serializeVisibilityResultForProduct:(NSString *)productId withVisiblity:(NSString *)visibility { NSDictionary *result = @{@"productId": productId, @"visibility": visibility}; - NSData *data = [NSJSONSerialization dataWithJSONObject:result options:0 error:nil]; - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSData *data = [NSJSONSerialization dataWithJSONObject: result options: 0 error: nil]; + return [[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding]; } // Note: this will need to be updated if Apple ever adds more StoreKit error codes. + (NSDictionary *)storeKitErrorCodeNames { return @{ - @(SKErrorUnknown) : @"SKErrorUnknown", - @(SKErrorClientInvalid) : @"SKErrorClientInvalid", - @(SKErrorPaymentCancelled) : @"SKErrorPaymentCancelled", - @(SKErrorPaymentInvalid) : @"SKErrorPaymentInvalid", - @(SKErrorPaymentNotAllowed) : @"SKErrorPaymentNotAllowed", + @(SKErrorUnknown): @"SKErrorUnknown", + @(SKErrorClientInvalid): @"SKErrorClientInvalid", + @(SKErrorPaymentCancelled): @"SKErrorPaymentCancelled", + @(SKErrorPaymentInvalid): @"SKErrorPaymentInvalid", + @(SKErrorPaymentNotAllowed): @"SKErrorPaymentNotAllowed", #if !MAC_APPSTORE - @(SKErrorStoreProductNotAvailable) : @"SKErrorStoreProductNotAvailable", - @(SKErrorCloudServicePermissionDenied) : @"SKErrorCloudServicePermissionDenied", - @(SKErrorCloudServiceNetworkConnectionFailed) : @"SKErrorCloudServiceNetworkConnectionFailed", + @(SKErrorStoreProductNotAvailable): @"SKErrorStoreProductNotAvailable", + @(SKErrorCloudServicePermissionDenied): @"SKErrorCloudServicePermissionDenied", + @(SKErrorCloudServiceNetworkConnectionFailed): @"SKErrorCloudServiceNetworkConnectionFailed", #endif #if !MAC_APPSTORE && (__IPHONE_OS_VERSION_MAX_ALLOWED >= 103000 || __TV_OS_VERSION_MAX_ALLOWED >= 103000) - @(SKErrorCloudServiceRevoked) : @"SKErrorCloudServiceRevoked", + @(SKErrorCloudServiceRevoked): @"SKErrorCloudServiceRevoked", #endif - }; + }; } #pragma mark - Internal Methods & Events -- (id)init { - if ( self = [super init] ) { +- (id)init +{ + if (self = [super init]) + { validProducts = [[NSMutableDictionary alloc] init]; pendingTransactions = [[NSMutableDictionary alloc] init]; finishedTransactions = [[NSMutableSet alloc] init]; @@ -863,8 +984,10 @@ - (id)init { UnityPurchasing* UnityPurchasing_instance = NULL; -UnityPurchasing* UnityPurchasing_getInstance() { - if (NULL == UnityPurchasing_instance) { +UnityPurchasing* UnityPurchasing_getInstance() +{ + if (NULL == UnityPurchasing_instance) + { UnityPurchasing_instance = [[UnityPurchasing alloc] init]; } return UnityPurchasing_instance; @@ -875,9 +998,10 @@ - (id)init { // which will free the string when it is garbage collected. // Stack allocated variables must not be returned as results // from managed to native calls. -char* UnityPurchasingMakeHeapAllocatedStringCopy (NSString* string) +char* UnityPurchasingMakeHeapAllocatedStringCopy(NSString* string) { - if (NULL == string) { + if (NULL == string) + { return NULL; } char* res = (char*)malloc([string length] + 1); @@ -885,111 +1009,132 @@ - (id)init { return res; } -void setUnityPurchasingCallback(UnityPurchasingCallback callback) { - [UnityPurchasing_getInstance() setCallback:callback]; +void setUnityPurchasingCallback(UnityPurchasingCallback callback) +{ + [UnityPurchasing_getInstance() setCallback: callback]; } -void unityPurchasingRetrieveProducts(const char* json) { - NSString* str = [NSString stringWithUTF8String:json]; - NSArray* productDefs = [UnityPurchasing deserializeProductDefs:str]; +void unityPurchasingRetrieveProducts(const char* json) +{ + NSString* str = [NSString stringWithUTF8String: json]; + NSArray* productDefs = [UnityPurchasing deserializeProductDefs: str]; NSMutableSet* productIds = [[NSMutableSet alloc] init]; - for (ProductDefinition* product in productDefs) { - [productIds addObject:product.storeSpecificId]; + for (ProductDefinition* product in productDefs) + { + [productIds addObject: product.storeSpecificId]; } - [UnityPurchasing_getInstance() requestProducts:productIds]; + [UnityPurchasing_getInstance() requestProducts: productIds]; } -void unityPurchasingPurchase(const char* json, const char* developerPayload) { - NSString* str = [NSString stringWithUTF8String:json]; - ProductDefinition* product = [UnityPurchasing deserializeProductDef:str]; - [UnityPurchasing_getInstance() purchaseProduct:product]; +void unityPurchasingPurchase(const char* json, const char* developerPayload) +{ + NSString* str = [NSString stringWithUTF8String: json]; + ProductDefinition* product = [UnityPurchasing deserializeProductDef: str]; + [UnityPurchasing_getInstance() purchaseProduct: product]; } -void unityPurchasingFinishTransaction(const char* productJSON, const char* transactionId) { +void unityPurchasingFinishTransaction(const char* productJSON, const char* transactionId) +{ if (transactionId == NULL) return; - NSString* tranId = [NSString stringWithUTF8String:transactionId]; - [UnityPurchasing_getInstance() finishTransaction:tranId]; + NSString* tranId = [NSString stringWithUTF8String: transactionId]; + [UnityPurchasing_getInstance() finishTransaction: tranId]; } -void unityPurchasingRestoreTransactions() { +void unityPurchasingRestoreTransactions() +{ UnityPurchasingLog(@"Restore transactions"); [UnityPurchasing_getInstance() restorePurchases]; } -void unityPurchasingAddTransactionObserver() { +void unityPurchasingAddTransactionObserver() +{ UnityPurchasingLog(@"Add transaction observer"); [UnityPurchasing_getInstance() addTransactionObserver]; } -void unityPurchasingRefreshAppReceipt() { +void unityPurchasingRefreshAppReceipt() +{ UnityPurchasingLog(@"Refresh app receipt"); [UnityPurchasing_getInstance() refreshReceipt]; } -char* getUnityPurchasingAppReceipt () { +char* getUnityPurchasingAppReceipt() +{ NSString* receipt = [UnityPurchasing_getInstance() getAppReceipt]; return UnityPurchasingMakeHeapAllocatedStringCopy(receipt); } -char* getUnityPurchasingTransactionReceiptForProductId (const char *productId) { - NSString* receipt = [UnityPurchasing_getInstance() getTransactionReceiptForProductId:[NSString stringWithUTF8String:productId]]; +char* getUnityPurchasingTransactionReceiptForProductId(const char *productId) +{ + NSString* receipt = [UnityPurchasing_getInstance() getTransactionReceiptForProductId: [NSString stringWithUTF8String: productId]]; return UnityPurchasingMakeHeapAllocatedStringCopy(receipt); } -BOOL getUnityPurchasingCanMakePayments () { +BOOL getUnityPurchasingCanMakePayments() +{ return [SKPaymentQueue canMakePayments]; } -void setSimulateAskToBuy(BOOL enabled) { +void setSimulateAskToBuy(BOOL enabled) +{ UnityPurchasingLog(@"Set simulate Ask To Buy %@", enabled ? @"true" : @"false"); UnityPurchasing_getInstance().simulateAskToBuyEnabled = enabled; } -BOOL getSimulateAskToBuy() { +BOOL getSimulateAskToBuy() +{ return UnityPurchasing_getInstance().simulateAskToBuyEnabled; } -void unityPurchasingSetApplicationUsername(const char *username) { +void unityPurchasingSetApplicationUsername(const char *username) +{ if (username == NULL) return; - UnityPurchasing_getInstance().applicationUsername = [NSString stringWithUTF8String:username]; + UnityPurchasing_getInstance().applicationUsername = [NSString stringWithUTF8String: username]; } -void unityPurchasingFetchStorePromotionOrder(void) { +void unityPurchasingFetchStorePromotionOrder(void) +{ [UnityPurchasing_getInstance() fetchStorePromotionOrder]; } -void unityPurchasingFetchStorePromotionVisibility(const char *productId) { - NSString* prodId = [NSString stringWithUTF8String:productId]; - [UnityPurchasing_getInstance() fetchStorePromotionVisibilityForProduct:prodId]; +void unityPurchasingFetchStorePromotionVisibility(const char *productId) +{ + NSString* prodId = [NSString stringWithUTF8String: productId]; + [UnityPurchasing_getInstance() fetchStorePromotionVisibilityForProduct: prodId]; } // Expects json in this format: // { "products": ["storeSpecificId1", "storeSpecificId2"] } -void unityPurchasingUpdateStorePromotionOrder(const char *json) { - NSString* str = [NSString stringWithUTF8String:json]; - NSArray* productIds = [UnityPurchasing deserializeProductIdList:str]; - [UnityPurchasing_getInstance() updateStorePromotionOrder:productIds]; +void unityPurchasingUpdateStorePromotionOrder(const char *json) +{ + NSString* str = [NSString stringWithUTF8String: json]; + NSArray* productIds = [UnityPurchasing deserializeProductIdList: str]; + [UnityPurchasing_getInstance() updateStorePromotionOrder: productIds]; } -void unityPurchasingUpdateStorePromotionVisibility(const char *productId, const char *visibility) { - NSString* prodId = [NSString stringWithUTF8String:productId]; - NSString* visibilityStr = [NSString stringWithUTF8String:visibility]; - [UnityPurchasing_getInstance() updateStorePromotionVisibility:visibilityStr forProduct:prodId]; +void unityPurchasingUpdateStorePromotionVisibility(const char *productId, const char *visibility) +{ + NSString* prodId = [NSString stringWithUTF8String: productId]; + NSString* visibilityStr = [NSString stringWithUTF8String: visibility]; + [UnityPurchasing_getInstance() updateStorePromotionVisibility: visibilityStr forProduct: prodId]; } -void unityPurchasingInterceptPromotionalPurchases() { +void unityPurchasingInterceptPromotionalPurchases() +{ UnityPurchasingLog(@"Intercept promotional purchases"); UnityPurchasing_getInstance().interceptPromotionalPurchases = YES; } -void unityPurchasingContinuePromotionalPurchases() { +void unityPurchasingContinuePromotionalPurchases() +{ UnityPurchasingLog(@"Continue promotional purchases"); [UnityPurchasing_getInstance() initiateQueuedEarlyTransactionObserverPayments]; } -void unityPurchasingPresentCodeRedemptionSheet() { +void unityPurchasingPresentCodeRedemptionSheet() +{ UnityPurchasingLog(@"Present code redemption sheet"); [UnityPurchasing_getInstance() presentCodeRedemptionSheet]; } diff --git a/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing b/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing index 32acd0504f24576e398a8314063d654f3e713094..e73c3c249ac9a416dcc7e3a37390eba9bfc99827 100644 GIT binary patch delta 187 zcmbQxCp4i?s9_6ZLlSeU^6Kr)NsPUWV3u$S|hv>94lJr(2?1e>!qJZSNCJrSHC8iK3yVs@KS|guEG4K)82a5f6R9}+|DG*w4F(m zS?&PXWaX>Ou@IA+uQBtbiafb*SCP}WbGLEsx`it(e(#E#zNc>cQfro*%nIyRwSTY6 m%TBbEP!llM*H7qswlp}C?`33% activeButtons = new List(); - private List activeListeners = new List (); + private List activeListeners = new List(); private static bool unityPurchasingInitialized; /// @@ -42,7 +44,8 @@ public class CodelessIAPStoreListener : IStoreListener public static bool initializationComplete; [RuntimeInitializeOnLoadMethod] - static void InitializeCodelessPurchasingOnLoad() { + static void InitializeCodelessPurchasingOnLoad() + { ProductCatalog catalog = ProductCatalog.LoadDefaultCatalog(); if (catalog.enableCodelessAutoInitialization && !catalog.IsEmpty() && instance == null) { @@ -112,15 +115,29 @@ public static CodelessIAPStoreListener Instance /// /// Creates the static instance of CodelessIAPStoreListener and initializes purchasing /// - private static void CreateCodelessIAPStoreListenerInstance() + private static async void CreateCodelessIAPStoreListenerInstance() { instance = new CodelessIAPStoreListener(); if (!unityPurchasingInitialized) { + await AutoInitializeUnityGamingServicesIfEnabled(); InitializePurchasing(); } } + private static Task AutoInitializeUnityGamingServicesIfEnabled() + { + return ShouldAutoInitUgs() + ? UnityServices.InitializeAsync() + : Task.CompletedTask; + } + + private static bool ShouldAutoInitUgs() + { + return instance.catalog.enableCodelessAutoInitialization && + instance.catalog.enableUnityGamingServicesAutoInitialization; + } + /// /// For advanced scripted IAP actions, use this session's after /// initialization. @@ -193,7 +210,7 @@ public void RemoveButton(IAPButton button) /// Listener to receive IAP purchasing events public void AddListener(IAPListener listener) { - activeListeners.Add (listener); + activeListeners.Add(listener); } /// @@ -202,7 +219,7 @@ public void AddListener(IAPListener listener) /// Listener to no longer receive IAP purchasing events public void RemoveListener(IAPListener listener) { - activeListeners.Remove (listener); + activeListeners.Remove(listener); } /// @@ -280,7 +297,8 @@ public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e) { result = button.ProcessPurchase(e); - if (result == PurchaseProcessingResult.Complete) { + if (result == PurchaseProcessingResult.Complete) + { consumePurchase = true; } @@ -293,7 +311,8 @@ public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e) { result = listener.ProcessPurchase(e); - if (result == PurchaseProcessingResult.Complete) { + if (result == PurchaseProcessingResult.Complete) + { consumePurchase = true; } @@ -302,7 +321,8 @@ public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e) } // we expect at least one receiver to get this message - if (!resultProcessed) { + if (!resultProcessed) + { Debug.LogError("Purchase not correctly processed for product \"" + e.purchasedProduct.definition.id + diff --git a/Runtime/Codeless/IAPButton.cs b/Runtime/Codeless/IAPButton.cs index d90bd52..6be2800 100644 --- a/Runtime/Codeless/IAPButton.cs +++ b/Runtime/Codeless/IAPButton.cs @@ -128,7 +128,8 @@ void OnEnable() if (buttonType == ButtonType.Purchase) { CodelessIAPStoreListener.Instance.AddButton(this); - if (CodelessIAPStoreListener.initializationComplete) { + if (CodelessIAPStoreListener.initializationComplete) + { UpdateText(); } } diff --git a/Runtime/Codeless/UnityEngine.Purchasing.Codeless.asmdef b/Runtime/Codeless/UnityEngine.Purchasing.Codeless.asmdef index d160f5b..3067877 100644 --- a/Runtime/Codeless/UnityEngine.Purchasing.Codeless.asmdef +++ b/Runtime/Codeless/UnityEngine.Purchasing.Codeless.asmdef @@ -1,6 +1,7 @@ { "name": "UnityEngine.Purchasing.Codeless", "references": [ + "Unity.Services.Core", "UnityEngine.Purchasing", "UnityEngine.Purchasing.Stores" ], @@ -13,4 +14,4 @@ "defineConstraints": [], "versionDefines": [], "noEngineReferences": false -} \ No newline at end of file +} diff --git a/Runtime/Common/INativeStore.cs b/Runtime/Common/INativeStore.cs index 3731f64..26f06d9 100644 --- a/Runtime/Common/INativeStore.cs +++ b/Runtime/Common/INativeStore.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing { @@ -8,26 +8,26 @@ namespace UnityEngine.Purchasing /// Is used by most public IStore implementations which themselves are owned by the purchasing /// core. /// - public interface INativeStore - { - /// - /// Call the Store to retrieve the store products. The `IStoreCallback` will be call with the retrieved products. - /// - /// The catalog of products to retrieve the store information from in JSON format. + public interface INativeStore + { + /// + /// Call the Store to retrieve the store products. The `IStoreCallback` will be call with the retrieved products. + /// + /// The catalog of products to retrieve the store information from in JSON format. void RetrieveProducts(String json); - /// - /// Call the Store to purchase a product. The `IStoreCallback` will be call when the purchase is successful. - /// - /// The product to buy in JSON format. - /// A string used by some stores to fight fraudulent transactions. + /// + /// Call the Store to purchase a product. The `IStoreCallback` will be call when the purchase is successful. + /// + /// The product to buy in JSON format. + /// A string used by some stores to fight fraudulent transactions. void Purchase(string productJSON, string developerPayload); - /// - /// Call the Store to consume a product. - /// - /// Product to consume in JSON format. - /// The transaction id of the receipt to close. + /// + /// Call the Store to consume a product. + /// + /// Product to consume in JSON format. + /// The transaction id of the receipt to close. void FinishTransaction(string productJSON, string transactionID); - } + } - internal delegate void UnityPurchasingCallback(string subject, string payload, string receipt, string transactionId); + internal delegate void UnityPurchasingCallback(string subject, string payload, string receipt, string transactionId); } diff --git a/Runtime/Common/MiniJSON.cs b/Runtime/Common/MiniJSON.cs index 2ef289d..e644ed2 100644 --- a/Runtime/Common/MiniJSON.cs +++ b/Runtime/Common/MiniJSON.cs @@ -1,31 +1,31 @@ -/* - * Copyright (c) 2013 Calvin Rien - * - * Based on the JSON parser by Patrick van Bergen - * http://techblog.procurios.nl/k/618/news/view/14605/14863/How-do-I-write-my-own-parser-for-JSON.html - * - * Simplified it so that it doesn't throw exceptions - * and can be used in Unity iPhone with maximum code stripping. - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * "Software"), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ +/* +* Copyright (c) 2013 Calvin Rien +* +* Based on the JSON parser by Patrick van Bergen +* http://techblog.procurios.nl/k/618/news/view/14605/14863/How-do-I-write-my-own-parser-for-JSON.html +* +* Simplified it so that it doesn't throw exceptions +* and can be used in Unity iPhone with maximum code stripping. +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ using System; using System.Collections; using System.Collections.Generic; @@ -84,23 +84,24 @@ public static class Json /// /// A JSON string. /// An List<object>, a Dictionary<string, object>, a double, an integer,a string, null, true, or false - public static object Deserialize (string json) + public static object Deserialize(string json) { // save the string for debug information - if (json == null) { + if (json == null) + { return null; } - return Parser.Parse (json); + return Parser.Parse(json); } sealed class Parser : IDisposable { const string WORD_BREAK = "{}[],:\""; - public static bool IsWordBreak (char c) + public static bool IsWordBreak(char c) { - return Char.IsWhiteSpace (c) || WORD_BREAK.IndexOf (c) != -1; + return Char.IsWhiteSpace(c) || WORD_BREAK.IndexOf(c) != -1; } enum TOKEN @@ -121,290 +122,320 @@ enum TOKEN StringReader json; - Parser (string jsonString) + Parser(string jsonString) { - json = new StringReader (jsonString); + json = new StringReader(jsonString); } - public static object Parse (string jsonString) + public static object Parse(string jsonString) { - using (var instance = new Parser (jsonString)) { - return instance.ParseValue (); + using (var instance = new Parser(jsonString)) + { + return instance.ParseValue(); } } - public void Dispose () + public void Dispose() { - json.Dispose (); + json.Dispose(); json = null; } - Dictionary ParseObject () + Dictionary ParseObject() { - Dictionary table = new Dictionary (); + Dictionary table = new Dictionary(); // ditch opening brace - json.Read (); + json.Read(); // { - while (true) { - switch (NextToken) { - case TOKEN.NONE: - return null; - case TOKEN.COMMA: - continue; - case TOKEN.CURLY_CLOSE: - return table; - default: - // name - string name = ParseString (); - if (name == null) { + while (true) + { + switch (NextToken) + { + case TOKEN.NONE: return null; - } + case TOKEN.COMMA: + continue; + case TOKEN.CURLY_CLOSE: + return table; + default: + // name + string name = ParseString(); + if (name == null) + { + return null; + } - // : - if (NextToken != TOKEN.COLON) { - return null; - } - // ditch the colon - json.Read (); + // : + if (NextToken != TOKEN.COLON) + { + return null; + } + // ditch the colon + json.Read(); - // value - table [name] = ParseValue (); - break; + // value + table[name] = ParseValue(); + break; } } } - List ParseArray () + List ParseArray() { - List array = new List (); + List array = new List(); // ditch opening bracket - json.Read (); + json.Read(); // [ var parsing = true; - while (parsing) { + while (parsing) + { TOKEN nextToken = NextToken; - switch (nextToken) { - case TOKEN.NONE: - return null; - case TOKEN.COMMA: - continue; - case TOKEN.SQUARED_CLOSE: - parsing = false; - break; - default: - object value = ParseByToken (nextToken); + switch (nextToken) + { + case TOKEN.NONE: + return null; + case TOKEN.COMMA: + continue; + case TOKEN.SQUARED_CLOSE: + parsing = false; + break; + default: + object value = ParseByToken(nextToken); - array.Add (value); - break; + array.Add(value); + break; } } return array; } - object ParseValue () + object ParseValue() { TOKEN nextToken = NextToken; - return ParseByToken (nextToken); + return ParseByToken(nextToken); } - object ParseByToken (TOKEN token) + object ParseByToken(TOKEN token) { - switch (token) { - case TOKEN.STRING: - return ParseString (); - case TOKEN.NUMBER: - return ParseNumber (); - case TOKEN.CURLY_OPEN: - return ParseObject (); - case TOKEN.SQUARED_OPEN: - return ParseArray (); - case TOKEN.TRUE: - return true; - case TOKEN.FALSE: - return false; - case TOKEN.NULL: - return null; - default: - return null; + switch (token) + { + case TOKEN.STRING: + return ParseString(); + case TOKEN.NUMBER: + return ParseNumber(); + case TOKEN.CURLY_OPEN: + return ParseObject(); + case TOKEN.SQUARED_OPEN: + return ParseArray(); + case TOKEN.TRUE: + return true; + case TOKEN.FALSE: + return false; + case TOKEN.NULL: + return null; + default: + return null; } } - string ParseString () + string ParseString() { - StringBuilder s = new StringBuilder (); + StringBuilder s = new StringBuilder(); char c; // ditch opening quote - json.Read (); + json.Read(); bool parsing = true; - while (parsing) { + while (parsing) + { - if (json.Peek () == -1) { + if (json.Peek() == -1) + { parsing = false; break; } c = NextChar; - switch (c) { - case '"': - parsing = false; - break; - case '\\': - if (json.Peek () == -1) { + switch (c) + { + case '"': parsing = false; break; - } - - c = NextChar; - switch (c) { - case '"': case '\\': - case '/': - s.Append (c); - break; - case 'b': - s.Append ('\b'); - break; - case 'f': - s.Append ('\f'); - break; - case 'n': - s.Append ('\n'); - break; - case 'r': - s.Append ('\r'); - break; - case 't': - s.Append ('\t'); - break; - case 'u': - var hex = new char [4]; - - for (int i = 0; i < 4; i++) { - hex [i] = NextChar; + if (json.Peek() == -1) + { + parsing = false; + break; } - s.Append ((char)Convert.ToInt32 (new string (hex), 16)); + c = NextChar; + switch (c) + { + case '"': + case '\\': + case '/': + s.Append(c); + break; + case 'b': + s.Append('\b'); + break; + case 'f': + s.Append('\f'); + break; + case 'n': + s.Append('\n'); + break; + case 'r': + s.Append('\r'); + break; + case 't': + s.Append('\t'); + break; + case 'u': + var hex = new char[4]; + + for (int i = 0; i < 4; i++) + { + hex[i] = NextChar; + } + + s.Append((char)Convert.ToInt32(new string(hex), 16)); + break; + } + break; + default: + s.Append(c); break; - } - break; - default: - s.Append (c); - break; } } - return s.ToString (); + return s.ToString(); } - object ParseNumber () + object ParseNumber() { string number = NextWord; - if (number.IndexOf ('.') == -1 && number.IndexOf ('e') == -1 && number.IndexOf ('E') == -1) { + if (number.IndexOf('.') == -1 && number.IndexOf('e') == -1 && number.IndexOf('E') == -1) + { long parsedInt; - Int64.TryParse (number, NumberStyles.Any, CultureInfo.InvariantCulture, out parsedInt); + Int64.TryParse(number, NumberStyles.Any, CultureInfo.InvariantCulture, out parsedInt); return parsedInt; } double parsedDouble; - Double.TryParse (number, NumberStyles.Any, CultureInfo.InvariantCulture, out parsedDouble); + Double.TryParse(number, NumberStyles.Any, CultureInfo.InvariantCulture, out parsedDouble); return parsedDouble; } - void EatWhitespace () + void EatWhitespace() { - while (Char.IsWhiteSpace (PeekChar)) { - json.Read (); + while (Char.IsWhiteSpace(PeekChar)) + { + json.Read(); - if (json.Peek () == -1) { + if (json.Peek() == -1) + { break; } } } - char PeekChar { - get { - return Convert.ToChar (json.Peek ()); + char PeekChar + { + get + { + return Convert.ToChar(json.Peek()); } } - char NextChar { - get { - return Convert.ToChar (json.Read ()); + char NextChar + { + get + { + return Convert.ToChar(json.Read()); } } - string NextWord { - get { - StringBuilder word = new StringBuilder (); + string NextWord + { + get + { + StringBuilder word = new StringBuilder(); - while (!IsWordBreak (PeekChar)) { - word.Append (NextChar); + while (!IsWordBreak(PeekChar)) + { + word.Append(NextChar); - if (json.Peek () == -1) { + if (json.Peek() == -1) + { break; } } - return word.ToString (); + return word.ToString(); } } - TOKEN NextToken { - get { - EatWhitespace (); + TOKEN NextToken + { + get + { + EatWhitespace(); - if (json.Peek () == -1) { + if (json.Peek() == -1) + { return TOKEN.NONE; } - switch (PeekChar) { - case '{': - return TOKEN.CURLY_OPEN; - case '}': - json.Read (); - return TOKEN.CURLY_CLOSE; - case '[': - return TOKEN.SQUARED_OPEN; - case ']': - json.Read (); - return TOKEN.SQUARED_CLOSE; - case ',': - json.Read (); - return TOKEN.COMMA; - case '"': - return TOKEN.STRING; - case ':': - return TOKEN.COLON; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case '-': - return TOKEN.NUMBER; + switch (PeekChar) + { + case '{': + return TOKEN.CURLY_OPEN; + case '}': + json.Read(); + return TOKEN.CURLY_CLOSE; + case '[': + return TOKEN.SQUARED_OPEN; + case ']': + json.Read(); + return TOKEN.SQUARED_CLOSE; + case ',': + json.Read(); + return TOKEN.COMMA; + case '"': + return TOKEN.STRING; + case ':': + return TOKEN.COLON; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + case '-': + return TOKEN.NUMBER; } - switch (NextWord) { - case "false": - return TOKEN.FALSE; - case "true": - return TOKEN.TRUE; - case "null": - return TOKEN.NULL; + switch (NextWord) + { + case "false": + return TOKEN.FALSE; + case "true": + return TOKEN.TRUE; + case "null": + return TOKEN.NULL; } return TOKEN.NONE; @@ -417,137 +448,159 @@ TOKEN NextToken { /// /// A Dictionary<string, object> / List<object> /// A JSON encoded string, or null if object 'json' is not serializable - public static string Serialize (object obj) + public static string Serialize(object obj) { - return Serializer.Serialize (obj); + return Serializer.Serialize(obj); } sealed class Serializer { StringBuilder builder; - Serializer () + Serializer() { - builder = new StringBuilder (); + builder = new StringBuilder(); } - public static string Serialize (object obj) + public static string Serialize(object obj) { - var instance = new Serializer (); + var instance = new Serializer(); - instance.SerializeValue (obj); + instance.SerializeValue(obj); - return instance.builder.ToString (); + return instance.builder.ToString(); } - void SerializeValue (object value) + void SerializeValue(object value) { IList asList; IDictionary asDict; string asStr; - if (value == null) { - builder.Append ("null"); - } else if ((asStr = value as string) != null) { - SerializeString (asStr); - } else if (value is bool) { - builder.Append ((bool)value ? "true" : "false"); - } else if ((asList = value as IList) != null) { - SerializeArray (asList); - } else if ((asDict = value as IDictionary) != null) { - SerializeObject (asDict); - } else if (value is char) { - SerializeString (new string ((char)value, 1)); - } else { - SerializeOther (value); + if (value == null) + { + builder.Append("null"); + } + else if ((asStr = value as string) != null) + { + SerializeString(asStr); + } + else if (value is bool) + { + builder.Append((bool)value ? "true" : "false"); + } + else if ((asList = value as IList) != null) + { + SerializeArray(asList); + } + else if ((asDict = value as IDictionary) != null) + { + SerializeObject(asDict); + } + else if (value is char) + { + SerializeString(new string((char)value, 1)); + } + else + { + SerializeOther(value); } } - void SerializeObject (IDictionary obj) + void SerializeObject(IDictionary obj) { bool first = true; - builder.Append ('{'); + builder.Append('{'); - foreach (object e in obj.Keys) { - if (!first) { - builder.Append (','); + foreach (object e in obj.Keys) + { + if (!first) + { + builder.Append(','); } - SerializeString (e.ToString ()); - builder.Append (':'); + SerializeString(e.ToString()); + builder.Append(':'); - SerializeValue (obj [e]); + SerializeValue(obj[e]); first = false; } - builder.Append ('}'); + builder.Append('}'); } - void SerializeArray (IList anArray) + void SerializeArray(IList anArray) { - builder.Append ('['); + builder.Append('['); bool first = true; - foreach (object obj in anArray) { - if (!first) { - builder.Append (','); + foreach (object obj in anArray) + { + if (!first) + { + builder.Append(','); } - SerializeValue (obj); + SerializeValue(obj); first = false; } - builder.Append (']'); + builder.Append(']'); } - void SerializeString (string str) + void SerializeString(string str) { - builder.Append ('\"'); + builder.Append('\"'); - char [] charArray = str.ToCharArray (); - foreach (var c in charArray) { - switch (c) { - case '"': - builder.Append ("\\\""); - break; - case '\\': - builder.Append ("\\\\"); - break; - case '\b': - builder.Append ("\\b"); - break; - case '\f': - builder.Append ("\\f"); - break; - case '\n': - builder.Append ("\\n"); - break; - case '\r': - builder.Append ("\\r"); - break; - case '\t': - builder.Append ("\\t"); - break; - default: - int codepoint = Convert.ToInt32 (c); - if ((codepoint >= 32) && (codepoint <= 126)) { - builder.Append (c); - } else { - builder.Append ("\\u"); - builder.Append (codepoint.ToString ("x4")); - } - break; + char[] charArray = str.ToCharArray(); + foreach (var c in charArray) + { + switch (c) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append("\\\\"); + break; + case '\b': + builder.Append("\\b"); + break; + case '\f': + builder.Append("\\f"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + default: + int codepoint = Convert.ToInt32(c); + if ((codepoint >= 32) && (codepoint <= 126)) + { + builder.Append(c); + } + else + { + builder.Append("\\u"); + builder.Append(codepoint.ToString("x4")); + } + break; } } - builder.Append ('\"'); + builder.Append('\"'); } - void SerializeOther (object value) + void SerializeOther(object value) { // Ensure we serialize Numbers using decimal (.) characters; avoid using comma (,) for // decimal separator. Use CultureInfo.InvariantCulture. @@ -555,195 +608,202 @@ void SerializeOther (object value) // NOTE: decimals lose precision during serialization. // They always have, I'm just letting you know. // Previously floats and doubles lost precision too. - if (value is float) { - builder.Append (((float)value).ToString ("R", CultureInfo.InvariantCulture)); - } else if (value is int - || value is uint - || value is long - || value is sbyte - || value is byte - || value is short - || value is ushort - || value is ulong) { - builder.Append (value); - } else if (value is double - || value is decimal) { - builder.Append (Convert.ToDouble (value).ToString ("R", CultureInfo.InvariantCulture)); - } else { - SerializeString (value.ToString ()); + if (value is float) + { + builder.Append(((float)value).ToString("R", CultureInfo.InvariantCulture)); + } + else if (value is int + || value is uint + || value is long + || value is sbyte + || value is byte + || value is short + || value is ushort + || value is ulong) + { + builder.Append(value); + } + else if (value is double + || value is decimal) + { + builder.Append(Convert.ToDouble(value).ToString("R", CultureInfo.InvariantCulture)); + } + else + { + SerializeString(value.ToString()); } } } } - // By Unity - #region Extension methods + // By Unity + #region Extension methods - /// - /// Extension class for MiniJson to access values in JSON format. - /// - public static class MiniJsonExtensions - { /// - /// Get the HashDictionary of a key in JSON dictionary. + /// Extension class for MiniJson to access values in JSON format. /// - /// The JSON in dictionary representations. - /// The Key to get the HashDictionary from in the JSON dictionary. - /// The HashDictionary found in the JSON - public static Dictionary GetHash(this Dictionary dic, string key) + public static class MiniJsonExtensions { - return (Dictionary) dic[key]; - } + /// + /// Get the HashDictionary of a key in JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the HashDictionary from in the JSON dictionary. + /// The HashDictionary found in the JSON + public static Dictionary GetHash(this Dictionary dic, string key) + { + return (Dictionary)dic[key]; + } - /// - /// Get the casted enum in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the casted enum from in the JSON dictionary. - /// The class to cast the enum. - /// The casted enum or will return T if the key was not found in the JSON dictionary. - public static T GetEnum(this Dictionary dic, string key) - { - if (dic.ContainsKey(key)) - return (T) Enum.Parse(typeof (T), dic[key].ToString(), true); + /// + /// Get the casted enum in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the casted enum from in the JSON dictionary. + /// The class to cast the enum. + /// The casted enum or will return T if the key was not found in the JSON dictionary. + public static T GetEnum(this Dictionary dic, string key) + { + if (dic.ContainsKey(key)) + return (T)Enum.Parse(typeof(T), dic[key].ToString(), true); - return default(T); - } + return default(T); + } - /// - /// Get the string in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the string from in the JSON dictionary. - /// The default value to send back if the JSON dictionary doesn't contains the key. - /// The string from the JSON dictionary or the default value if there is none - public static string GetString(this Dictionary dic, string key, string defaultValue = "") - { - if (dic.ContainsKey(key)) - return dic[key].ToString(); + /// + /// Get the string in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the string from in the JSON dictionary. + /// The default value to send back if the JSON dictionary doesn't contains the key. + /// The string from the JSON dictionary or the default value if there is none + public static string GetString(this Dictionary dic, string key, string defaultValue = "") + { + if (dic.ContainsKey(key)) + return dic[key].ToString(); - return defaultValue; - } + return defaultValue; + } - /// - /// Get the long in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the long from in the JSON dictionary. - /// The long from the JSON dictionary or 0 if the key was not found in the JSON dictionary - public static long GetLong(this Dictionary dic, string key) - { - if (dic.ContainsKey(key)) - return long.Parse(dic[key].ToString()); + /// + /// Get the long in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the long from in the JSON dictionary. + /// The long from the JSON dictionary or 0 if the key was not found in the JSON dictionary + public static long GetLong(this Dictionary dic, string key) + { + if (dic.ContainsKey(key)) + return long.Parse(dic[key].ToString()); - return 0; - } + return 0; + } - /// - /// Get the list of strings in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the list of strings from in the JSON dictionary. - /// The list of strings from the JSON dictionary or an empty list of strings if the key was not found in the JSON dictionary - public static List GetStringList(this Dictionary dic, string key) - { - if (dic.ContainsKey(key)) + /// + /// Get the list of strings in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the list of strings from in the JSON dictionary. + /// The list of strings from the JSON dictionary or an empty list of strings if the key was not found in the JSON dictionary + public static List GetStringList(this Dictionary dic, string key) { - List result = new List(); - var objs = (List) dic[key]; - foreach (var v in objs) - result.Add(v.ToString()); - return result; - } + if (dic.ContainsKey(key)) + { + List result = new List(); + var objs = (List)dic[key]; + foreach (var v in objs) + result.Add(v.ToString()); + return result; + } - return new List(); - } + return new List(); + } - /// - /// Get the bool in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the bool from in the JSON dictionary. - /// The bool from the JSON dictionary or false if the key was not found in the JSON dictionary - public static bool GetBool(this Dictionary dic, string key) - { - if (dic.ContainsKey(key)) - return bool.Parse(dic[key].ToString()); + /// + /// Get the bool in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the bool from in the JSON dictionary. + /// The bool from the JSON dictionary or false if the key was not found in the JSON dictionary + public static bool GetBool(this Dictionary dic, string key) + { + if (dic.ContainsKey(key)) + return bool.Parse(dic[key].ToString()); - return false; - } + return false; + } - /// - /// Get the casted object in the JSON dictionary. - /// - /// The JSON in dictionary representations. - /// The Key to get the casted object from in the JSON dictionary. - /// The class to cast the object. - /// The casted object or will return T if the key was not found in the JSON dictionary. - public static T Get(this Dictionary dic, string key) - { - if (dic.ContainsKey(key)) - return (T) dic[key]; + /// + /// Get the casted object in the JSON dictionary. + /// + /// The JSON in dictionary representations. + /// The Key to get the casted object from in the JSON dictionary. + /// The class to cast the object. + /// The casted object or will return T if the key was not found in the JSON dictionary. + public static T Get(this Dictionary dic, string key) + { + if (dic.ContainsKey(key)) + return (T)dic[key]; - return default(T); - } + return default(T); + } - /// - /// Convert a Dictionary to JSON. - /// - /// The dictionary to convert to JSON. - /// The converted dictionary in JSON string format. - public static string toJson(this Dictionary obj) - { - return MiniJson.JsonEncode(obj); - } + /// + /// Convert a Dictionary to JSON. + /// + /// The dictionary to convert to JSON. + /// The converted dictionary in JSON string format. + public static string toJson(this Dictionary obj) + { + return MiniJson.JsonEncode(obj); + } - /// - /// Convert a Dictionary to JSON. - /// - /// The dictionary to convert to JSON. - /// The converted dictionary in JSON string format. - public static string toJson(this Dictionary obj) - { - return MiniJson.JsonEncode(obj); - } + /// + /// Convert a Dictionary to JSON. + /// + /// The dictionary to convert to JSON. + /// The converted dictionary in JSON string format. + public static string toJson(this Dictionary obj) + { + return MiniJson.JsonEncode(obj); + } - /// - /// Convert a string array to JSON. - /// - /// The string array to convert to JSON. - /// The converted dictionary in JSON string format. - public static string toJson(this string[] array) - { - var list = new List(); - foreach (var s in array) - list.Add(s); + /// + /// Convert a string array to JSON. + /// + /// The string array to convert to JSON. + /// The converted dictionary in JSON string format. + public static string toJson(this string[] array) + { + var list = new List(); + foreach (var s in array) + list.Add(s); - return MiniJson.JsonEncode(list); - } + return MiniJson.JsonEncode(list); + } - /// - /// Convert string JSON into List of Objects. - /// - /// String JSON to convert. - /// List of Objects converted from string json. - public static List ArrayListFromJson(this string json) - { - return MiniJson.JsonDecode(json) as List; - } + /// + /// Convert string JSON into List of Objects. + /// + /// String JSON to convert. + /// List of Objects converted from string json. + public static List ArrayListFromJson(this string json) + { + return MiniJson.JsonDecode(json) as List; + } - /// - /// Convert string JSON into Dictionary. - /// - /// String JSON to convert. - /// Dictionary converted from string json. - public static Dictionary HashtableFromJson(this string json) - { - return MiniJson.JsonDecode(json) as Dictionary; + /// + /// Convert string JSON into Dictionary. + /// + /// String JSON to convert. + /// Dictionary converted from string json. + public static Dictionary HashtableFromJson(this string json) + { + return MiniJson.JsonDecode(json) as Dictionary; + } } - } - #endregion + #endregion } /// @@ -756,9 +816,9 @@ public class MiniJson /// /// Object to convert to JSON string /// JSON string - public static string JsonEncode (object json) + public static string JsonEncode(object json) { - return MiniJSON.Json.Serialize (json); + return MiniJSON.Json.Serialize(json); } /// @@ -766,9 +826,9 @@ public static string JsonEncode (object json) /// /// String to convert to JSON object /// JSON object - public static object JsonDecode (string json) + public static object JsonDecode(string json) { - return MiniJSON.Json.Deserialize (json); + return MiniJSON.Json.Deserialize(json); } } } diff --git a/Runtime/Common/VersionCheck.cs b/Runtime/Common/VersionCheck.cs index ece396a..fb31621 100644 --- a/Runtime/Common/VersionCheck.cs +++ b/Runtime/Common/VersionCheck.cs @@ -1,133 +1,146 @@ -using System; +using System; namespace UnityEngine.Purchasing { - /// - /// Utility class for comparing Unity versions. This class only compares the major, minor, and patch versions and - /// ignores the suffixes (e.g. f2, p3, b1) - /// - internal static class VersionCheck - { - /// - /// Represents a three-part version number as three ints - /// - internal struct Version - { - public int major; - public int minor; - public int patch; - } + /// + /// Utility class for comparing Unity versions. This class only compares the major, minor, and patch versions and + /// ignores the suffixes (e.g. f2, p3, b1) + /// + internal static class VersionCheck + { + /// + /// Represents a three-part version number as three ints + /// + internal struct Version + { + public int major; + public int minor; + public int patch; + } - /// - /// Returns true if versionA is greater than or equal to versionB - /// - public static bool GreaterThanOrEqual(string versionA, string versionB) - { - return !LessThan(versionA, versionB); - } + /// + /// Returns true if versionA is greater than or equal to versionB + /// + public static bool GreaterThanOrEqual(string versionA, string versionB) + { + return !LessThan(versionA, versionB); + } - /// - /// Returns true if versionA is greater than versionB - /// - public static bool GreaterThan(string versionA, string versionB) - { - return !LessThanOrEqual(versionA, versionB); - } + /// + /// Returns true if versionA is greater than versionB + /// + public static bool GreaterThan(string versionA, string versionB) + { + return !LessThanOrEqual(versionA, versionB); + } - /// - /// Returns true if versionA is less than versionB - /// - public static bool LessThan(string versionA, string versionB) - { - var va = Parse(versionA); - var vb = Parse(versionB); + /// + /// Returns true if versionA is less than versionB + /// + public static bool LessThan(string versionA, string versionB) + { + var va = Parse(versionA); + var vb = Parse(versionB); - if (va.major > vb.major) { - return false; - } else if (va.major < vb.major) { - return true; - } else if (va.minor > vb.minor) { - return false; - } else if (va.minor < vb.minor) { - return true; - } else if (va.patch > vb.patch) { - return false; - } else if (va.patch < vb.patch) { - return true; - } else { - return false; - } - } + if (va.major > vb.major) + { + return false; + } + else if (va.major < vb.major) + { + return true; + } + else if (va.minor > vb.minor) + { + return false; + } + else if (va.minor < vb.minor) + { + return true; + } + else if (va.patch > vb.patch) + { + return false; + } + else if (va.patch < vb.patch) + { + return true; + } + else + { + return false; + } + } - /// - /// Returns true if versionA is less than or equal to versionB - /// - public static bool LessThanOrEqual(string versionA, string versionB) - { - return LessThan(versionA, versionB) || !LessThan(versionB, versionA); - } + /// + /// Returns true if versionA is less than or equal to versionB + /// + public static bool LessThanOrEqual(string versionA, string versionB) + { + return LessThan(versionA, versionB) || !LessThan(versionB, versionA); + } - /// - /// Returns true if versionA is equal to versionB - /// - public static bool Equal(string versionA, string versionB) - { - return !LessThan(versionA, versionB) && !LessThan(versionB, versionA); - } + /// + /// Returns true if versionA is equal to versionB + /// + public static bool Equal(string versionA, string versionB) + { + return !LessThan(versionA, versionB) && !LessThan(versionB, versionA); + } - /// - /// Parse the major version from a version string as an int. If the version is "3.2.1", this function returns 3. - /// - /// The major version, or 0 if it cannot be parsed - public static int MajorVersion(string version) - { - return PartialVersion(version, 0); - } + /// + /// Parse the major version from a version string as an int. If the version is "3.2.1", this function returns 3. + /// + /// The major version, or 0 if it cannot be parsed + public static int MajorVersion(string version) + { + return PartialVersion(version, 0); + } - /// - /// Parse the minor version from a version string as an int. If the version is "3.2.1", this function returns 2. - /// - /// The minor version, or 0 if it cannot be parsed - public static int MinorVersion(string version) - { - return PartialVersion(version, 1); - } + /// + /// Parse the minor version from a version string as an int. If the version is "3.2.1", this function returns 2. + /// + /// The minor version, or 0 if it cannot be parsed + public static int MinorVersion(string version) + { + return PartialVersion(version, 1); + } - /// - /// Parse the patch version from a version string as an int. If the version is "3.2.1", this function returns 1. - /// - /// The patch version, or 0 if it cannot be parsed - public static int PatchVersion(string version) - { - return PartialVersion(version, 2); - } + /// + /// Parse the patch version from a version string as an int. If the version is "3.2.1", this function returns 1. + /// + /// The patch version, or 0 if it cannot be parsed + public static int PatchVersion(string version) + { + return PartialVersion(version, 2); + } - public static Version Parse(string version) - { - Version v = new Version(); - v.major = MajorVersion(version); - v.minor = MinorVersion(version); - v.patch = PatchVersion(version); - return v; - } + public static Version Parse(string version) + { + Version v = new Version(); + v.major = MajorVersion(version); + v.minor = MinorVersion(version); + v.patch = PatchVersion(version); + return v; + } - /// - /// Retrieve a part of a version number by index - /// - /// The parsed version, or 0 if the number can't be parsed. - private static int PartialVersion(string version, int index) - { - // remove suffix - string[] parts = version.Split(new char[] { 'a', 'b', 'f', 'p' }); - var prefix = parts[0]; + /// + /// Retrieve a part of a version number by index + /// + /// The parsed version, or 0 if the number can't be parsed. + private static int PartialVersion(string version, int index) + { + // remove suffix + string[] parts = version.Split(new char[] { 'a', 'b', 'f', 'p' }); + var prefix = parts[0]; - int result = 0; - string[] versions = prefix.Split('.'); - if (versions.Length < index + 1) - return result; - - int.TryParse(versions[index], out result); - return result; - } - } + int result = 0; + string[] versions = prefix.Split('.'); + if (versions.Length < index + 1) + return result; + + int.TryParse(versions[index], out result); + return result; + } + } } diff --git a/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs index e6fd36c..5659dbb 100644 --- a/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs +++ b/Runtime/Purchasing/Analytics/AnalyticsAdapter.cs @@ -88,7 +88,7 @@ Unity.Services.Analytics.Product GenerateRealCurrencySpentOnPurchase(Product pro long ExtractRealCurrencyAmount(Product product) { return m_Analytics.ConvertCurrencyToMinorUnits(product.metadata.isoCurrencyCode, - (double) product.metadata.localizedPrice); + (double)product.metadata.localizedPrice); } } } diff --git a/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs index d2d27f7..d2fb4a6 100644 --- a/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs +++ b/Runtime/Purchasing/Analytics/Legacy/LegacyUnityAnalytics.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Unity.Services.Analytics; namespace UnityEngine.Purchasing { @@ -9,12 +8,16 @@ class LegacyUnityAnalytics : ILegacyUnityAnalytics public void SendTransactionEvent(string productId, Decimal amount, string currency, string receiptPurchaseData, string signature) { +#if ENABLE_CLOUD_SERVICES_ANALYTICS Analytics.Analytics.Transaction(productId, amount, currency, receiptPurchaseData, signature); +#endif } public void SendCustomEvent(string name, Dictionary data) { +#if ENABLE_CLOUD_SERVICES_ANALYTICS Analytics.Analytics.CustomEvent(name, data); +#endif } } } diff --git a/Runtime/Purchasing/ConfigurationBuilder.cs b/Runtime/Purchasing/ConfigurationBuilder.cs index f3fc23f..bb7fe80 100644 --- a/Runtime/Purchasing/ConfigurationBuilder.cs +++ b/Runtime/Purchasing/ConfigurationBuilder.cs @@ -82,11 +82,11 @@ internal ConfigurationBuilder(PurchasingFactory factory) /// Whether or not the project will use the catalog stored on the cloud or the one cached locally. /// /// True if the project will use the catalog stored on the cloud. - public bool useCatalogProvider - { - get; - set; - } + public bool useCatalogProvider + { + get; + set; + } /// /// The set of products in the catalog. @@ -188,7 +188,8 @@ public ConfigurationBuilder AddProduct(string id, ProductType type, IDs storeIDs /// The instance of the configuration builder with the new product added. public ConfigurationBuilder AddProducts(IEnumerable products) { - foreach (var product in products) { + foreach (var product in products) + { m_Products.Add(product); } diff --git a/Runtime/Purchasing/CoreServices.meta b/Runtime/Purchasing/CoreServices.meta new file mode 100644 index 0000000..8790cab --- /dev/null +++ b/Runtime/Purchasing/CoreServices.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9c1b1fe2371b41aaba927178a8cd7ec4 +timeCreated: 1653582075 \ No newline at end of file diff --git a/Runtime/Purchasing/CoreServices/Interfaces.meta b/Runtime/Purchasing/CoreServices/Interfaces.meta new file mode 100644 index 0000000..94e94bf --- /dev/null +++ b/Runtime/Purchasing/CoreServices/Interfaces.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 83b14ff67c40407fb8772d37e2961a3d +timeCreated: 1653582100 \ No newline at end of file diff --git a/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs b/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs new file mode 100644 index 0000000..54b3bb8 --- /dev/null +++ b/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs @@ -0,0 +1,9 @@ +using Unity.Services.Core; + +namespace UnityEngine.Purchasing +{ + interface IUnityServicesInitializationChecker + { + void CheckAndLogWarning(); + } +} diff --git a/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs.meta b/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs.meta new file mode 100644 index 0000000..b1f706e --- /dev/null +++ b/Runtime/Purchasing/CoreServices/Interfaces/IUnityServicesInitializationChecker.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 234f58cbe0c0452c9fdfe8644391aa5e +timeCreated: 1653509656 \ No newline at end of file diff --git a/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs b/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs new file mode 100644 index 0000000..119b69c --- /dev/null +++ b/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs @@ -0,0 +1,39 @@ +using Unity.Services.Core; + +namespace UnityEngine.Purchasing +{ + class UnityServicesInitializationChecker : IUnityServicesInitializationChecker + { + const string UgsUninitializedMessage = + "Unity In-App Purchasing requires Unity Gaming Services to have been initialized before use.\n" + + "- Find out how to initialize Unity Gaming Services by following the documentation https://docs.unity.com/ugs-overview/services-core-api.html#InitializationExample\n" + + "or download the 06 Initialize Gaming Services sample from Package Manager > In-App Purchasing > Samples.\n" + + "- If you are using the codeless API, you may want to enable the enable Unity Gaming Services automatic initialization " + + "by checking the Automatically initialize Unity Gaming Services checkbox at the bottom of the IAP Catalog window"; + + ILogger m_Logger; + + public UnityServicesInitializationChecker(ILogger logger) + { + m_Logger = logger; + } + + public void CheckAndLogWarning() + { + if (IsUninitialized()) + { + LogWarning(); + } + } + + bool IsUninitialized() + { + return UnityServices.State == ServicesInitializationState.Uninitialized; + } + + void LogWarning() + { + m_Logger.LogIAPWarning(UgsUninitializedMessage); + } + } +} diff --git a/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs.meta b/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs.meta new file mode 100644 index 0000000..e4d0f85 --- /dev/null +++ b/Runtime/Purchasing/CoreServices/UnityServicesInitializationChecker.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e18ca1f2d22b4701b8985b0fbcaa9ceb +timeCreated: 1653509444 \ No newline at end of file diff --git a/Runtime/Purchasing/PayoutDefinition.cs b/Runtime/Purchasing/PayoutDefinition.cs index 47cf21d..eb75735 100644 --- a/Runtime/Purchasing/PayoutDefinition.cs +++ b/Runtime/Purchasing/PayoutDefinition.cs @@ -25,11 +25,14 @@ public class PayoutDefinition /// /// Type of the payout /// - public PayoutType type { - get { + public PayoutType type + { + get + { return m_Type; } - private set { + private set + { m_Type = value; } } @@ -38,9 +41,11 @@ private set { /// /// Type of the payout as a string /// - public string typeString { - get { - return m_Type.ToString (); + public string typeString + { + get + { + return m_Type.ToString(); } } @@ -53,11 +58,14 @@ public string typeString { /// /// Subtype of the payout /// - public string subtype { - get { + public string subtype + { + get + { return m_Subtype; } - private set { + private set + { if (value.Length > MaxSubtypeLength) throw new ArgumentException(string.Format("subtype cannot be longer than {0}", MaxSubtypeLength)); m_Subtype = value; @@ -67,11 +75,14 @@ private set { /// /// Quantity or value of the payout /// - public double quantity { - get { + public double quantity + { + get + { return m_Quantity; } - private set { + private set + { m_Quantity = value; } } @@ -84,13 +95,16 @@ private set { /// /// Payload data of the payout /// - public string data { - get { + public string data + { + get + { return m_Data; } - private set { + private set + { if (value.Length > MaxDataLength) - throw new ArgumentException (string.Format ("data cannot be longer than {0}", MaxDataLength)); + throw new ArgumentException(string.Format("data cannot be longer than {0}", MaxDataLength)); m_Data = value; } } @@ -108,7 +122,7 @@ public PayoutDefinition() /// The payout type, as a string. /// The payout subtype. /// The payout quantity. - public PayoutDefinition (string typeString, string subtype, double quantity) : this (typeString, subtype, quantity, string.Empty) + public PayoutDefinition(string typeString, string subtype, double quantity) : this(typeString, subtype, quantity, string.Empty) { } @@ -119,11 +133,11 @@ public PayoutDefinition (string typeString, string subtype, double quantity) : t /// The payout subtype. /// The payout quantity. /// The payout data. - public PayoutDefinition (string typeString, string subtype, double quantity, string data) + public PayoutDefinition(string typeString, string subtype, double quantity, string data) { PayoutType t = PayoutType.Other; if (Enum.IsDefined(typeof(PayoutType), typeString)) - t = (PayoutType)Enum.Parse (typeof (PayoutType), typeString); + t = (PayoutType)Enum.Parse(typeof(PayoutType), typeString); this.type = t; this.subtype = subtype; @@ -136,7 +150,7 @@ public PayoutDefinition (string typeString, string subtype, double quantity, str /// /// The payout subtype. /// The payout quantity. - public PayoutDefinition (string subtype, double quantity) : this (PayoutType.Other, subtype, quantity, string.Empty) + public PayoutDefinition(string subtype, double quantity) : this(PayoutType.Other, subtype, quantity, string.Empty) { } @@ -146,7 +160,7 @@ public PayoutDefinition (string subtype, double quantity) : this (PayoutType.Oth /// The payout subtype. /// The payout quantity. /// The payout data. - public PayoutDefinition (string subtype, double quantity, string data) : this (PayoutType.Other, subtype, quantity, data) + public PayoutDefinition(string subtype, double quantity, string data) : this(PayoutType.Other, subtype, quantity, data) { } @@ -156,7 +170,7 @@ public PayoutDefinition (string subtype, double quantity, string data) : this (P /// The payout type. /// The payout subtype. /// The payout quantity. - public PayoutDefinition (PayoutType type, string subtype, double quantity) : this (type, subtype, quantity, string.Empty) + public PayoutDefinition(PayoutType type, string subtype, double quantity) : this(type, subtype, quantity, string.Empty) { } @@ -167,7 +181,7 @@ public PayoutDefinition (PayoutType type, string subtype, double quantity) : thi /// The payout subtype. /// The payout quantity. /// The payout data. - public PayoutDefinition (PayoutType type, string subtype, double quantity, string data) + public PayoutDefinition(PayoutType type, string subtype, double quantity, string data) { this.type = type; this.subtype = subtype; diff --git a/Runtime/Purchasing/ProductDefinition.cs b/Runtime/Purchasing/ProductDefinition.cs index 7404553..98550ba 100644 --- a/Runtime/Purchasing/ProductDefinition.cs +++ b/Runtime/Purchasing/ProductDefinition.cs @@ -126,8 +126,10 @@ public override int GetHashCode() /// Gets all payouts attached to this product. /// /// The payouts. - public IEnumerable payouts { - get { + public IEnumerable payouts + { + get + { return m_Payouts; } } @@ -136,8 +138,10 @@ public IEnumerable payouts { /// Gets the first attached payout. This is a shortcut for the case where only one payout is attached to the product. /// /// The payout. - public PayoutDefinition payout { - get { + public PayoutDefinition payout + { + get + { return m_Payouts.Count > 0 ? m_Payouts[0] : null; } } diff --git a/Runtime/Purchasing/PurchasingFactory.cs b/Runtime/Purchasing/PurchasingFactory.cs index 92a8b6b..8b3bbf2 100644 --- a/Runtime/Purchasing/PurchasingFactory.cs +++ b/Runtime/Purchasing/PurchasingFactory.cs @@ -13,7 +13,7 @@ internal class PurchasingFactory : IPurchasingBinder, IExtensionProvider private Dictionary m_ConfigMap = new Dictionary(); private Dictionary m_ExtensionMap = new Dictionary(); private IStore m_Store; - private ICatalogProvider m_CatalogProvider; + private ICatalogProvider m_CatalogProvider; public PurchasingFactory(IPurchasingModule first, params IPurchasingModule[] remainingModules) { @@ -95,19 +95,19 @@ public T GetExtension() where T : IStoreExtension throw new ArgumentException("No binding for type " + t); } - public void SetCatalogProvider (ICatalogProvider provider) - { - m_CatalogProvider = provider; - } - - public void SetCatalogProviderFunction(Action>> func) - { - m_CatalogProvider = new SimpleCatalogProvider (func); - } - - internal ICatalogProvider GetCatalogProvider () - { - return m_CatalogProvider; - } - } + public void SetCatalogProvider(ICatalogProvider provider) + { + m_CatalogProvider = provider; + } + + public void SetCatalogProviderFunction(Action>> func) + { + m_CatalogProvider = new SimpleCatalogProvider(func); + } + + internal ICatalogProvider GetCatalogProvider() + { + return m_CatalogProvider; + } + } } diff --git a/Runtime/Purchasing/PurchasingManager.cs b/Runtime/Purchasing/PurchasingManager.cs index 88a1983..7d7269e 100644 --- a/Runtime/Purchasing/PurchasingManager.cs +++ b/Runtime/Purchasing/PurchasingManager.cs @@ -16,6 +16,7 @@ internal class PurchasingManager : IStoreCallback, IStoreController private ILogger m_Logger; private TransactionLog m_TransactionLog; private string m_StoreName; + private IUnityServicesInitializationChecker m_UnityServicesInitializationChecker; private Action m_AdditionalProductsCallback; private Action m_AdditionalProductsFailCallback; @@ -24,13 +25,14 @@ internal class PurchasingManager : IStoreCallback, IStoreController /// public bool useTransactionLog { get; set; } - internal PurchasingManager(TransactionLog tDb, ILogger logger, IStore store, string storeName) + 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) @@ -45,9 +47,11 @@ public void InitiatePurchase(string productId) public void InitiatePurchase(Product product, string developerPayload) { + m_UnityServicesInitializationChecker.CheckAndLogWarning(); + if (null == product) { - m_Logger.LogWarning("Unity IAP", "Trying to purchase null Product"); + m_Logger.LogIAPWarning("Trying to purchase null Product"); return; } @@ -76,13 +80,13 @@ public void ConfirmPendingPurchase(Product product) { if (null == product) { - m_Logger.LogError("Unity IAP", "Unable to confirm purchase with null Product"); + m_Logger.LogIAPError("Unable to confirm purchase with null Product"); return; } if (string.IsNullOrEmpty(product.transactionID)) { - m_Logger.LogError("Unity IAP", "Unable to confirm purchase; Product has missing or empty transactionID"); + m_Logger.LogIAPError("Unable to confirm purchase; Product has missing or empty transactionID"); return; } diff --git a/Runtime/Purchasing/SimpleCatalogProvider.cs b/Runtime/Purchasing/SimpleCatalogProvider.cs index ed519e2..7e13323 100644 --- a/Runtime/Purchasing/SimpleCatalogProvider.cs +++ b/Runtime/Purchasing/SimpleCatalogProvider.cs @@ -4,20 +4,21 @@ namespace UnityEngine.Purchasing { - internal class SimpleCatalogProvider : ICatalogProvider - { - private Action>> m_Func; + internal class SimpleCatalogProvider : ICatalogProvider + { + private Action>> m_Func; - internal SimpleCatalogProvider (Action>> func) - { - m_Func = func; - } + internal SimpleCatalogProvider(Action>> func) + { + m_Func = func; + } - public void FetchProducts (Action> callback) - { - if (m_Func != null) { - m_Func (callback); - } - } - } + public void FetchProducts(Action> callback) + { + if (m_Func != null) + { + m_Func(callback); + } + } + } } diff --git a/Runtime/Purchasing/UnifiedReceipt.cs b/Runtime/Purchasing/UnifiedReceipt.cs index f0a7ed6..e672abe 100644 --- a/Runtime/Purchasing/UnifiedReceipt.cs +++ b/Runtime/Purchasing/UnifiedReceipt.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing { diff --git a/Runtime/Purchasing/UnityPurchasing.cs b/Runtime/Purchasing/UnityPurchasing.cs index 262560e..8ac2dcf 100644 --- a/Runtime/Purchasing/UnityPurchasing.cs +++ b/Runtime/Purchasing/UnityPurchasing.cs @@ -17,8 +17,12 @@ public abstract class UnityPurchasing /// The ConfigurationBuilder containing the product definitions mapped to stores public static void Initialize(IStoreListener listener, ConfigurationBuilder builder) { - Initialize(listener, builder, UnityEngine.Debug.unityLogger, Application.persistentDataPath, - GenerateUnityAnalytics(), GenerateLegacyUnityAnalytics(), builder.factory.GetCatalogProvider()); + var logger = Debug.unityLogger; + var unityServicesInitializationChecker = new UnityServicesInitializationChecker(logger); + + Initialize(listener, builder, logger, Application.persistentDataPath, + GenerateUnityAnalytics(), GenerateLegacyUnityAnalytics(), builder.factory.GetCatalogProvider(), + unityServicesInitializationChecker); } private static IAnalyticsAdapter GenerateUnityAnalytics() @@ -57,15 +61,18 @@ public static void ClearTransactionLog() /// internal static void Initialize(IStoreListener listener, ConfigurationBuilder builder, ILogger logger, string persistentDatapath, IAnalyticsAdapter analytics, IAnalyticsAdapter legacyAnalytics, - ICatalogProvider catalog) + ICatalogProvider catalog, IUnityServicesInitializationChecker unityServicesInitializationChecker) { + unityServicesInitializationChecker.CheckAndLogWarning(); + var transactionLog = new TransactionLog(logger, persistentDatapath); - var manager = new PurchasingManager(transactionLog, logger, builder.factory.service, builder.factory.storeName); + var manager = new PurchasingManager(transactionLog, logger, builder.factory.service, + builder.factory.storeName, unityServicesInitializationChecker); var analyticsClient = new AnalyticsClient(analytics, legacyAnalytics); // Proxy the PurchasingManager's callback interface to forward Transactions to Analytics. var proxy = new StoreListenerProxy(listener, analyticsClient, builder.factory); - FetchAndMergeProducts(builder.useCatalogProvider, builder.products, catalog, response => + FetchAndMergeProducts(builder.useCatalogProvider, builder.products, catalog, response => { manager.Initialize(proxy, response); }); @@ -74,20 +81,21 @@ internal static void Initialize(IStoreListener listener, ConfigurationBuilder bu internal static void FetchAndMergeProducts(bool useCatalog, HashSet localProductSet, ICatalogProvider catalog, Action> callback) { - if (useCatalog && catalog != null) + if (useCatalog && catalog != null) { - catalog.FetchProducts(cloudProducts => { - var updatedProductSet = new HashSet(localProductSet); + catalog.FetchProducts(cloudProducts => + { + var updatedProductSet = new HashSet(localProductSet); - foreach (var product in cloudProducts) - { - // Products are hashed by id, so this should remove the local product with the same id before adding the cloud product - updatedProductSet.Remove(product); - updatedProductSet.Add(product); - } + foreach (var product in cloudProducts) + { + // Products are hashed by id, so this should remove the local product with the same id before adding the cloud product + updatedProductSet.Remove(product); + updatedProductSet.Add(product); + } - callback(updatedProductSet); - }); + callback(updatedProductSet); + }); } else { diff --git a/Runtime/Purchasing/Utilites/ILoggerExtensions.cs b/Runtime/Purchasing/Utilites/ILoggerExtensions.cs new file mode 100644 index 0000000..4fb9391 --- /dev/null +++ b/Runtime/Purchasing/Utilites/ILoggerExtensions.cs @@ -0,0 +1,17 @@ +namespace UnityEngine.Purchasing +{ + static class LoggerExtensions + { + const string IAPLogTag = "Unity IAP"; + + public static void LogIAPWarning(this ILogger logger, string message) + { + logger.LogWarning(IAPLogTag, message); + } + + public static void LogIAPError(this ILogger logger, string message) + { + logger.LogError(IAPLogTag, message); + } + } +} diff --git a/Runtime/Purchasing/Utilites/ILoggerExtensions.cs.meta b/Runtime/Purchasing/Utilites/ILoggerExtensions.cs.meta new file mode 100644 index 0000000..9c4a621 --- /dev/null +++ b/Runtime/Purchasing/Utilites/ILoggerExtensions.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 90ee04e64daf47d2ac9f878e9cd367bf +timeCreated: 1653587304 \ No newline at end of file diff --git a/Runtime/Purchasing/Utilites/ProductPurchaseUpdater.cs b/Runtime/Purchasing/Utilites/ProductPurchaseUpdater.cs index 1502dac..28d0e5b 100644 --- a/Runtime/Purchasing/Utilites/ProductPurchaseUpdater.cs +++ b/Runtime/Purchasing/Utilites/ProductPurchaseUpdater.cs @@ -4,7 +4,7 @@ internal static class ProductPurchaseUpdater { internal static void UpdateProductReceiptAndTransactionID(Product product, string receipt, string transactionId, string storeName) { - product.receipt = UnifiedReceiptFormatter.FormatUnifiedReceipt(receipt, transactionId, storeName); + product.receipt = UnifiedReceiptFormatter.FormatUnifiedReceipt(receipt, transactionId, storeName); product.transactionID = transactionId; } } diff --git a/Runtime/Security/AppleValidator.cs b/Runtime/Security/AppleValidator.cs index e517afc..2ee4af4 100644 --- a/Runtime/Security/AppleValidator.cs +++ b/Runtime/Security/AppleValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -14,13 +14,13 @@ namespace UnityEngine.Purchasing.Security public class AppleValidator { private X509Cert cert; - private AppleReceiptParser parser = new AppleReceiptParser (); + private AppleReceiptParser parser = new AppleReceiptParser(); /// /// Constructs an instance with Apple Certificate. /// /// The apple certificate. - public AppleValidator (byte[] appleRootCertificate) + public AppleValidator(byte[] appleRootCertificate) { cert = new X509Cert(appleRootCertificate); } @@ -31,12 +31,13 @@ public AppleValidator (byte[] appleRootCertificate) /// The Apple receipt to validate. /// The parsed AppleReceipt /// The exception thrown if the receipt is incorrectly signed. - public AppleReceipt Validate (byte [] receiptData) + public AppleReceipt Validate(byte[] receiptData) { PKCS7 receipt; - var result = parser.Parse (receiptData, out receipt); - if (!receipt.Verify (cert, result.receiptCreationDate)) { - throw new InvalidSignatureException (); + var result = parser.Parse(receiptData, out receipt); + if (!receipt.Verify(cert, result.receiptCreationDate)) + { + throw new InvalidSignatureException(); } return result; } @@ -58,12 +59,12 @@ public class AppleReceiptParser /// /// Apple receipt data /// The converted AppleReceipt object from the Apple receipt data - public AppleReceipt Parse (byte [] receiptData) + public AppleReceipt Parse(byte[] receiptData) { return Parse(receiptData, out _); } - internal AppleReceipt Parse (byte [] receiptData, out PKCS7 receipt) + internal AppleReceipt Parse(byte[] receiptData, out PKCS7 receipt) { // Avoid Culture-sensitive parsing for the duration of this method CultureInfo originalCulture = PushInvariantCultureOnThread(); @@ -75,16 +76,18 @@ internal AppleReceipt Parse (byte [] receiptData, out PKCS7 receipt) if (_mostRecentReceiptData.ContainsKey(k_AppleReceiptKey) && _mostRecentReceiptData.ContainsKey(k_PKCS7Key) && _mostRecentReceiptData.ContainsKey(k_ReceiptBytesKey) && - ArrayEquals(receiptData, (byte[])_mostRecentReceiptData[k_ReceiptBytesKey])) { + ArrayEquals(receiptData, (byte[])_mostRecentReceiptData[k_ReceiptBytesKey])) + { receipt = (PKCS7)_mostRecentReceiptData[k_PKCS7Key]; return (AppleReceipt)_mostRecentReceiptData[k_AppleReceiptKey]; } - using (var stm = new System.IO.MemoryStream (receiptData)) { - Asn1Parser parser = new Asn1Parser (); - parser.LoadData (stm); - receipt = new PKCS7 (parser.RootNode); - var result = ParseReceipt (receipt.data); + using (var stm = new System.IO.MemoryStream(receiptData)) + { + Asn1Parser parser = new Asn1Parser(); + parser.LoadData(stm); + receipt = new PKCS7(parser.RootNode); + var result = ParseReceipt(receipt.data); // Cache the receipt info _mostRecentReceiptData[k_AppleReceiptKey] = result; @@ -126,53 +129,57 @@ private static void PopCultureOffThread(CultureInfo originalCulture) Thread.CurrentThread.CurrentCulture = originalCulture; } - private AppleReceipt ParseReceipt (Asn1Node data) + private AppleReceipt ParseReceipt(Asn1Node data) { - if (data == null || data.ChildNodeCount != 1) { - throw new InvalidPKCS7Data (); + if (data == null || data.ChildNodeCount != 1) + { + throw new InvalidPKCS7Data(); } Asn1Node set = GetSetNode(data); - var result = new AppleReceipt (); - var inApps = new List (); + var result = new AppleReceipt(); + var inApps = new List(); - for (int t = 0; t < set.ChildNodeCount; t++) { - var node = set.GetChildNode (t); + for (int t = 0; t < set.ChildNodeCount; t++) + { + var node = set.GetChildNode(t); // Each node should contain three children. - if (node.ChildNodeCount == 3) { - var type = Asn1Util.BytesToLong (node.GetChildNode (0).Data); - var value = node.GetChildNode (2); + if (node.ChildNodeCount == 3) + { + var type = Asn1Util.BytesToLong(node.GetChildNode(0).Data); + var value = node.GetChildNode(2); // See https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1 - switch (type) { - case 2: - result.bundleID = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; - case 3: - result.appVersion = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; - case 4: - result.opaque = value.Data; - break; - case 5: - result.hash = value.Data; - break; - case 12: - var dateString = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - result.receiptCreationDate = DateTime.Parse (dateString).ToUniversalTime (); - break; - case 17: - inApps.Add (ParseInAppReceipt (value.GetChildNode (0))); - break; - case 19: - result.originalApplicationVersion = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; + switch (type) + { + case 2: + result.bundleID = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; + case 3: + result.appVersion = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; + case 4: + result.opaque = value.Data; + break; + case 5: + result.hash = value.Data; + break; + case 12: + var dateString = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + result.receiptCreationDate = DateTime.Parse(dateString).ToUniversalTime(); + break; + case 17: + inApps.Add(ParseInAppReceipt(value.GetChildNode(0))); + break; + case 19: + result.originalApplicationVersion = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; } } } - result.inAppPurchaseReceipts = inApps.ToArray (); + result.inAppPurchaseReceipts = inApps.ToArray(); return result; } @@ -192,56 +199,59 @@ private Asn1Node GetSetNode(Asn1Node data) } } - private AppleInAppPurchaseReceipt ParseInAppReceipt (Asn1Node inApp) + private AppleInAppPurchaseReceipt ParseInAppReceipt(Asn1Node inApp) { - var result = new AppleInAppPurchaseReceipt (); - for (int t = 0; t < inApp.ChildNodeCount; t++) { - var node = inApp.GetChildNode (t); - if (node.ChildNodeCount == 3) { - var type = Asn1Util.BytesToLong (node.GetChildNode (0).Data); - var value = node.GetChildNode (2); - switch (type) { - case 1701: - result.quantity = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data); - break; - case 1702: - result.productID = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; - case 1703: - result.transactionID = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; - case 1705: - result.originalTransactionIdentifier = Encoding.UTF8.GetString (value.GetChildNode (0).Data); - break; - case 1704: - result.purchaseDate = TryParseDateTimeNode (value); - break; - case 1706: - result.originalPurchaseDate = TryParseDateTimeNode (value); - break; - case 1708: - result.subscriptionExpirationDate = TryParseDateTimeNode (value); - break; - case 1712: - result.cancellationDate = TryParseDateTimeNode (value); - break; + var result = new AppleInAppPurchaseReceipt(); + for (int t = 0; t < inApp.ChildNodeCount; t++) + { + var node = inApp.GetChildNode(t); + if (node.ChildNodeCount == 3) + { + var type = Asn1Util.BytesToLong(node.GetChildNode(0).Data); + var value = node.GetChildNode(2); + switch (type) + { + case 1701: + result.quantity = (int)Asn1Util.BytesToLong(value.GetChildNode(0).Data); + break; + case 1702: + result.productID = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; + case 1703: + result.transactionID = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; + case 1705: + result.originalTransactionIdentifier = Encoding.UTF8.GetString(value.GetChildNode(0).Data); + break; + case 1704: + result.purchaseDate = TryParseDateTimeNode(value); + break; + case 1706: + result.originalPurchaseDate = TryParseDateTimeNode(value); + break; + case 1708: + result.subscriptionExpirationDate = TryParseDateTimeNode(value); + break; + case 1712: + result.cancellationDate = TryParseDateTimeNode(value); + break; - case 1707: - // looks like possibly a type? - result.productType = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data); - break; + case 1707: + // looks like possibly a type? + result.productType = (int)Asn1Util.BytesToLong(value.GetChildNode(0).Data); + break; - case 1713: - // looks like possibly is_trial? - result.isFreeTrial = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data); - break; + case 1713: + // looks like possibly is_trial? + result.isFreeTrial = (int)Asn1Util.BytesToLong(value.GetChildNode(0).Data); + break; - case 1719: - result.isIntroductoryPricePeriod = (int)Asn1Util.BytesToLong (value.GetChildNode (0).Data); - break; + case 1719: + result.isIntroductoryPricePeriod = (int)Asn1Util.BytesToLong(value.GetChildNode(0).Data); + break; - default: - break; + default: + break; } @@ -254,11 +264,12 @@ private AppleInAppPurchaseReceipt ParseInAppReceipt (Asn1Node inApp) /// /// Try and parse a DateTime, returning the minimum DateTime on failure. /// - private static DateTime TryParseDateTimeNode (Asn1Node node) + private static DateTime TryParseDateTimeNode(Asn1Node node) { - var dateString = Encoding.UTF8.GetString (node.GetChildNode (0).Data); - if (!string.IsNullOrEmpty (dateString)) { - return DateTime.Parse (dateString).ToUniversalTime (); + var dateString = Encoding.UTF8.GetString(node.GetChildNode(0).Data); + if (!string.IsNullOrEmpty(dateString)) + { + return DateTime.Parse(dateString).ToUniversalTime(); } return DateTime.MinValue; @@ -273,13 +284,16 @@ private static DateTime TryParseDateTimeNode (Asn1Node node) /// Second object to validate against first object. /// Type of object to check. /// Returns true if they are the same length and contain the same information or else returns false. - public static bool ArrayEquals(T[] a, T[] b) where T: IEquatable { - if (a.Length != b.Length) { + public static bool ArrayEquals(T[] a, T[] b) where T : IEquatable + { + if (a.Length != b.Length) + { return false; } for (int i = 0; i < a.Length; i++) { - if (!a[i].Equals(b[i])) { + if (!a[i].Equals(b[i])) + { return false; } } diff --git a/Runtime/Security/Asn1Processor/Asn1Node.cs b/Runtime/Security/Asn1Processor/Asn1Node.cs index 80cc6ee..b1655a0 100644 --- a/Runtime/Security/Asn1Processor/Asn1Node.cs +++ b/Runtime/Security/Asn1Processor/Asn1Node.cs @@ -34,1265 +34,1265 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// Asn1Node, implemented IAsn1Node interface. - /// - internal class Asn1Node : IAsn1Node - { - // PrivateMembers - private byte tag; - private long dataOffset; - private long dataLength; - private long lengthFieldBytes; - private byte[] data; - private ArrayList childNodeList; - private byte unusedBits; - private long deepness; - private string path = ""; - private const int indentStep = 3; - private Asn1Node parentNode; - private bool requireRecalculatePar = true; - private bool isIndefiniteLength = false; - private bool parseEncapsulatedData = true; - - /// - /// Default Asn1Node text line length. - /// - public const int defaultLineLen = 80; - - /// - /// Minium line length. - /// - public const int minLineLen = 60; + /// + /// Asn1Node, implemented IAsn1Node interface. + /// + internal class Asn1Node : IAsn1Node + { + // PrivateMembers + private byte tag; + private long dataOffset; + private long dataLength; + private long lengthFieldBytes; + private byte[] data; + private ArrayList childNodeList; + private byte unusedBits; + private long deepness; + private string path = ""; + private const int indentStep = 3; + private Asn1Node parentNode; + private bool requireRecalculatePar = true; + private bool isIndefiniteLength = false; + private bool parseEncapsulatedData = true; + + /// + /// Default Asn1Node text line length. + /// + public const int defaultLineLen = 80; + + /// + /// Minium line length. + /// + public const int minLineLen = 60; const int k_EndOfStream = -1; const int k_InvalidIndeterminateContentLength = -1; const int k_IndefiniteLengthFooterSize = 2; - private Asn1Node(Asn1Node parentNode, long dataOffset) - { - Init(); - deepness = parentNode.Deepness + 1; - this.parentNode = parentNode; - this.dataOffset = dataOffset; - } - - private void Init() - { - childNodeList = new ArrayList(); - data = null; - dataLength = 0; - lengthFieldBytes = 0; - unusedBits = 0; - tag = Asn1Tag.SEQUENCE | Asn1TagClasses.CONSTRUCTED; - childNodeList.Clear(); - deepness = 0; - parentNode = null; - } - - private string GetHexPrintingStr(Asn1Node startNode, string baseLine, - string lStr, int lineLen) - { - string nodeStr = ""; - string iStr = GetIndentStr(startNode); - string dataStr = Asn1Util.ToHexString(data); - if (dataStr.Length > 0) - { - if (baseLine.Length + dataStr.Length < lineLen) - { - nodeStr += baseLine + "'" + dataStr + "'"; - } - else - { - nodeStr += baseLine + FormatLineHexString( - lStr, - iStr.Length, - lineLen, - dataStr - ); - } - } - else - { - nodeStr += baseLine; - } - return nodeStr + "\r\n"; - } - - private string FormatLineString(string lStr, int indent, int lineLen, string msg) - { - string retval = ""; - indent += indentStep; - int realLen = lineLen - indent; - int sLen = indent; - int currentp; - for (currentp = 0; currentp < msg.Length; currentp += realLen) - { - if (currentp+realLen > msg.Length) - { - retval += "\r\n" + lStr + Asn1Util.GenStr(sLen,' ') + - "'" + msg.Substring(currentp, msg.Length - currentp) + "'"; - } - else - { - retval += "\r\n" + lStr + Asn1Util.GenStr(sLen,' ') + "'" + - msg.Substring(currentp, realLen) + "'"; - } - } - return retval; - } - - private string FormatLineHexString(string lStr, int indent, int lineLen, string msg) - { - string retval = ""; - indent += indentStep; - int realLen = lineLen - indent; - int sLen = indent; - int currentp; - for (currentp = 0; currentp < msg.Length; currentp += realLen) - { - if (currentp+realLen > msg.Length) - { - retval += "\r\n" + lStr + Asn1Util.GenStr(sLen,' ') + - msg.Substring(currentp, msg.Length - currentp); - } - else - { - retval += "\r\n" + lStr + Asn1Util.GenStr(sLen,' ') + - msg.Substring(currentp, realLen); - } - } - return retval; - } - - - //PublicMembers - - /// - /// Constructor, initialize all the members. - /// - public Asn1Node() - { - Init(); - dataOffset = 0; - } - - /// - /// Get/Set isIndefiniteLength. - /// - public bool IsIndefiniteLength - { - get - { - return isIndefiniteLength; - } - set - { - isIndefiniteLength = value; - } - } - - /// - /// Clone a new Asn1Node by current node. - /// - /// new node. - public Asn1Node Clone() - { - MemoryStream ms = new MemoryStream(); - this.SaveData(ms); - ms.Position = 0; - Asn1Node node = new Asn1Node(); - node.LoadData(ms); - return node; - } - - /// - /// Get/Set tag value. - /// - public byte Tag - { - get - { - return tag; - } - set - { - tag = value; - } - } - - public byte MaskedTag - { - get - { - return (byte)(tag & Asn1Tag.TAG_MASK); - } - } - - /// - /// Load data from byte[]. - /// - /// byte[] - /// true:Succeed; false:failed. - public bool LoadData(byte[] byteData) - { - bool retval = true; - try - { - MemoryStream ms = new MemoryStream(byteData); - ms.Position = 0; - retval = LoadData(ms); - ms.Close(); - } - catch - { - retval = false; - } - return retval; - } - - /// - /// Retrieve all the node count in the node subtree. - /// - /// starting node. - /// long integer node count in the node subtree. - public static long GetDescendantNodeCount(Asn1Node node) - { - long count =0; - count += node.ChildNodeCount; - for (int i=0; i - /// Load data from Stream. Start from current position. - /// This function sets requireRecalculatePar to false then calls InternalLoadData - /// to complish the task. - /// - /// Stream - /// true:Succeed; false:failed. - public bool LoadData(Stream xdata) - { - bool retval = false; - try - { - RequireRecalculatePar = false; - retval = InternalLoadData(xdata); - return retval; - } - finally - { - RequireRecalculatePar = true; - RecalculateTreePar(); - } - } - - /// - /// Call SaveData and return byte[] as result instead stream. - /// - /// - public byte[] GetRawData() - { - MemoryStream ms = new MemoryStream(); - SaveData(ms); - byte[] retval = new byte[ms.Length]; - ms.Position = 0; - ms.Read(retval, 0, (int)ms.Length); - ms.Close(); - return retval; - } - - /// - /// Get if data is empty. - /// - public bool IsEmptyData - { - get - { - if (data == null) return true; - if (data.Length < 1) - return true; - else - return false; - } - } - - /// - /// Save node data into Stream. - /// - /// Stream. - /// true:Succeed; false:failed. - public bool SaveData(Stream xdata) - { - bool retval = true; - long nodeCount = ChildNodeCount; - xdata.WriteByte(tag); - Asn1Util.DERLengthEncode(xdata, (ulong) dataLength); - if ((tag) == Asn1Tag.BIT_STRING) - { - xdata.WriteByte(unusedBits); - } - if (nodeCount==0) - { - if (data != null) - { - xdata.Write(data, 0, data.Length); - } - } - else - { - Asn1Node tempNode; - int i; - for (i=0; i - /// Clear data and children list. - /// - public void ClearAll() - { - data = null; - Asn1Node tempNode; - for (int i=0; i - /// Add child node at the end of children list. - /// - /// the node that will be add in. - public void AddChild(Asn1Node xdata) - { - childNodeList.Add(xdata); - RecalculateTreePar(); - } - - /// - /// Insert a node in the children list before the pointed index. - /// - /// Asn1Node - /// 0 based index. - /// New node index. - public int InsertChild(Asn1Node xdata, int index) - { - childNodeList.Insert(index, xdata); - RecalculateTreePar(); - return index; - } - - /// - /// Insert a node in the children list before the pointed node. - /// - /// Asn1Node that will be instered in the children list. - /// Index node. - /// New node index. - public int InsertChild(Asn1Node xdata, Asn1Node indexNode) - { - int index = childNodeList.IndexOf(indexNode); - childNodeList.Insert(index, xdata); - RecalculateTreePar(); - return index; - } - - /// - /// Insert a node in the children list after the pointed node. - /// - /// Asn1Node - /// Index node. - /// New node index. - public int InsertChildAfter(Asn1Node xdata, Asn1Node indexNode) - { - int index = childNodeList.IndexOf(indexNode)+1; - childNodeList.Insert(index, xdata); - RecalculateTreePar(); - return index; - } - - /// - /// Insert a node in the children list after the pointed node. - /// - /// Asn1Node that will be instered in the children list. - /// 0 based index. - /// New node index. - public int InsertChildAfter(Asn1Node xdata, int index) - { - int xindex = index+1; - childNodeList.Insert(xindex, xdata); - RecalculateTreePar(); - return xindex; - } - - /// - /// Remove a child from children node list by index. - /// - /// 0 based index. - /// The Asn1Node just removed from the list. - public Asn1Node RemoveChild(int index) - { - Asn1Node retval = null; - if (index < (childNodeList.Count - 1)) - { - retval = (Asn1Node) childNodeList[index+1]; - } - childNodeList.RemoveAt(index); - if (retval == null) - { - if (childNodeList.Count > 0) + private Asn1Node(Asn1Node parentNode, long dataOffset) + { + Init(); + deepness = parentNode.Deepness + 1; + this.parentNode = parentNode; + this.dataOffset = dataOffset; + } + + private void Init() + { + childNodeList = new ArrayList(); + data = null; + dataLength = 0; + lengthFieldBytes = 0; + unusedBits = 0; + tag = Asn1Tag.SEQUENCE | Asn1TagClasses.CONSTRUCTED; + childNodeList.Clear(); + deepness = 0; + parentNode = null; + } + + private string GetHexPrintingStr(Asn1Node startNode, string baseLine, + string lStr, int lineLen) + { + string nodeStr = ""; + string iStr = GetIndentStr(startNode); + string dataStr = Asn1Util.ToHexString(data); + if (dataStr.Length > 0) + { + if (baseLine.Length + dataStr.Length < lineLen) + { + nodeStr += baseLine + "'" + dataStr + "'"; + } + else + { + nodeStr += baseLine + FormatLineHexString( + lStr, + iStr.Length, + lineLen, + dataStr + ); + } + } + else + { + nodeStr += baseLine; + } + return nodeStr + "\r\n"; + } + + private string FormatLineString(string lStr, int indent, int lineLen, string msg) + { + string retval = ""; + indent += indentStep; + int realLen = lineLen - indent; + int sLen = indent; + int currentp; + for (currentp = 0; currentp < msg.Length; currentp += realLen) + { + if (currentp + realLen > msg.Length) + { + retval += "\r\n" + lStr + Asn1Util.GenStr(sLen, ' ') + + "'" + msg.Substring(currentp, msg.Length - currentp) + "'"; + } + else + { + retval += "\r\n" + lStr + Asn1Util.GenStr(sLen, ' ') + "'" + + msg.Substring(currentp, realLen) + "'"; + } + } + return retval; + } + + private string FormatLineHexString(string lStr, int indent, int lineLen, string msg) + { + string retval = ""; + indent += indentStep; + int realLen = lineLen - indent; + int sLen = indent; + int currentp; + for (currentp = 0; currentp < msg.Length; currentp += realLen) + { + if (currentp + realLen > msg.Length) + { + retval += "\r\n" + lStr + Asn1Util.GenStr(sLen, ' ') + + msg.Substring(currentp, msg.Length - currentp); + } + else + { + retval += "\r\n" + lStr + Asn1Util.GenStr(sLen, ' ') + + msg.Substring(currentp, realLen); + } + } + return retval; + } + + + //PublicMembers + + /// + /// Constructor, initialize all the members. + /// + public Asn1Node() + { + Init(); + dataOffset = 0; + } + + /// + /// Get/Set isIndefiniteLength. + /// + public bool IsIndefiniteLength + { + get + { + return isIndefiniteLength; + } + set + { + isIndefiniteLength = value; + } + } + + /// + /// Clone a new Asn1Node by current node. + /// + /// new node. + public Asn1Node Clone() + { + MemoryStream ms = new MemoryStream(); + this.SaveData(ms); + ms.Position = 0; + Asn1Node node = new Asn1Node(); + node.LoadData(ms); + return node; + } + + /// + /// Get/Set tag value. + /// + public byte Tag + { + get + { + return tag; + } + set + { + tag = value; + } + } + + public byte MaskedTag + { + get + { + return (byte)(tag & Asn1Tag.TAG_MASK); + } + } + + /// + /// Load data from byte[]. + /// + /// byte[] + /// true:Succeed; false:failed. + public bool LoadData(byte[] byteData) + { + bool retval = true; + try + { + MemoryStream ms = new MemoryStream(byteData); + ms.Position = 0; + retval = LoadData(ms); + ms.Close(); + } + catch + { + retval = false; + } + return retval; + } + + /// + /// Retrieve all the node count in the node subtree. + /// + /// starting node. + /// long integer node count in the node subtree. + public static long GetDescendantNodeCount(Asn1Node node) + { + long count = 0; + count += node.ChildNodeCount; + for (int i = 0; i < node.ChildNodeCount; i++) + { + count += GetDescendantNodeCount(node.GetChildNode(i)); + } + return count; + } + + /// + /// Load data from Stream. Start from current position. + /// This function sets requireRecalculatePar to false then calls InternalLoadData + /// to complish the task. + /// + /// Stream + /// true:Succeed; false:failed. + public bool LoadData(Stream xdata) + { + bool retval = false; + try + { + RequireRecalculatePar = false; + retval = InternalLoadData(xdata); + return retval; + } + finally + { + RequireRecalculatePar = true; + RecalculateTreePar(); + } + } + + /// + /// Call SaveData and return byte[] as result instead stream. + /// + /// + public byte[] GetRawData() + { + MemoryStream ms = new MemoryStream(); + SaveData(ms); + byte[] retval = new byte[ms.Length]; + ms.Position = 0; + ms.Read(retval, 0, (int)ms.Length); + ms.Close(); + return retval; + } + + /// + /// Get if data is empty. + /// + public bool IsEmptyData + { + get + { + if (data == null) return true; + if (data.Length < 1) + return true; + else + return false; + } + } + + /// + /// Save node data into Stream. + /// + /// Stream. + /// true:Succeed; false:failed. + public bool SaveData(Stream xdata) + { + bool retval = true; + long nodeCount = ChildNodeCount; + xdata.WriteByte(tag); + Asn1Util.DERLengthEncode(xdata, (ulong)dataLength); + if ((tag) == Asn1Tag.BIT_STRING) + { + xdata.WriteByte(unusedBits); + } + if (nodeCount == 0) + { + if (data != null) + { + xdata.Write(data, 0, data.Length); + } + } + else + { + Asn1Node tempNode; + int i; + for (i = 0; i < nodeCount; i++) + { + tempNode = GetChildNode(i); + retval = tempNode.SaveData(xdata); + } + } + return retval; + } + + /// + /// Clear data and children list. + /// + public void ClearAll() + { + data = null; + Asn1Node tempNode; + for (int i = 0; i < childNodeList.Count; i++) + { + tempNode = (Asn1Node)childNodeList[i]; + tempNode.ClearAll(); + } + childNodeList.Clear(); + RecalculateTreePar(); + } + + + /// + /// Add child node at the end of children list. + /// + /// the node that will be add in. + public void AddChild(Asn1Node xdata) + { + childNodeList.Add(xdata); + RecalculateTreePar(); + } + + /// + /// Insert a node in the children list before the pointed index. + /// + /// Asn1Node + /// 0 based index. + /// New node index. + public int InsertChild(Asn1Node xdata, int index) + { + childNodeList.Insert(index, xdata); + RecalculateTreePar(); + return index; + } + + /// + /// Insert a node in the children list before the pointed node. + /// + /// Asn1Node that will be instered in the children list. + /// Index node. + /// New node index. + public int InsertChild(Asn1Node xdata, Asn1Node indexNode) + { + int index = childNodeList.IndexOf(indexNode); + childNodeList.Insert(index, xdata); + RecalculateTreePar(); + return index; + } + + /// + /// Insert a node in the children list after the pointed node. + /// + /// Asn1Node + /// Index node. + /// New node index. + public int InsertChildAfter(Asn1Node xdata, Asn1Node indexNode) + { + int index = childNodeList.IndexOf(indexNode) + 1; + childNodeList.Insert(index, xdata); + RecalculateTreePar(); + return index; + } + + /// + /// Insert a node in the children list after the pointed node. + /// + /// Asn1Node that will be instered in the children list. + /// 0 based index. + /// New node index. + public int InsertChildAfter(Asn1Node xdata, int index) + { + int xindex = index + 1; + childNodeList.Insert(xindex, xdata); + RecalculateTreePar(); + return xindex; + } + + /// + /// Remove a child from children node list by index. + /// + /// 0 based index. + /// The Asn1Node just removed from the list. + public Asn1Node RemoveChild(int index) + { + Asn1Node retval = null; + if (index < (childNodeList.Count - 1)) + { + retval = (Asn1Node)childNodeList[index + 1]; + } + childNodeList.RemoveAt(index); + if (retval == null) + { + if (childNodeList.Count > 0) { retval = GetLastChild(); } - else - { - retval = this; - } - } - RecalculateTreePar(); - return retval; - } + else + { + retval = this; + } + } + RecalculateTreePar(); + return retval; + } Asn1Node GetLastChild() { - return (Asn1Node) childNodeList[childNodeList.Count - 1]; + return (Asn1Node)childNodeList[childNodeList.Count - 1]; } /// - /// Remove the child from children node list. - /// - /// The node needs to be removed. - /// - public Asn1Node RemoveChild(Asn1Node node) - { - Asn1Node retval = null; - int i = childNodeList.IndexOf(node); - retval = RemoveChild(i); - return retval; - } - - /// - /// Get child node count. - /// - public long ChildNodeCount - { - get - { - return childNodeList.Count; - } - } - - /// - /// Retrieve child node by index. - /// - /// 0 based index. - /// 0 based index. - public Asn1Node GetChildNode(int index) - { - Asn1Node retval = null; - if (index < ChildNodeCount) - { - retval = (Asn1Node) childNodeList[index]; - } - return retval; - } - - - - /// - /// Get tag name. - /// - public string TagName - { - get - { - return Asn1Util.GetTagName(tag); - } - } - - /// - /// Get parent node. - /// - public Asn1Node ParentNode - { - get - { - return parentNode; - } - } - - /// - /// Get the node and all the descendents text description. - /// - /// starting node. - /// line length. - /// - public string GetText(Asn1Node startNode, int lineLen) - { - string nodeStr = ""; - string baseLine = ""; - string dataStr = ""; - const string lStr = " | | | "; - string oid, oidName; - switch (tag) - { - case Asn1Tag.BIT_STRING: - baseLine = - String.Format("{0,6}|{1,6}|{2,7}|{3} {4} UnusedBits:{5} : ", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName, - unusedBits - ); - dataStr = Asn1Util.ToHexString(data); - if (baseLine.Length + dataStr.Length < lineLen) - { - if (dataStr.Length<1) - { - nodeStr += baseLine + "\r\n"; - } - else - { - nodeStr += baseLine + "'" + dataStr + "'\r\n"; - } - } - else - { - nodeStr += baseLine + FormatLineHexString( - lStr, - GetIndentStr(startNode).Length, - lineLen, - dataStr + "\r\n" - ); - } - break; - case Asn1Tag.OBJECT_IDENTIFIER: - Oid xoid = new Oid(); - oid = xoid.Decode(new MemoryStream(data)); - oidName = xoid.GetOidName(oid); - nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5} [{6}]\r\n", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName, - oidName, - oid - ); - break; - case Asn1Tag.RELATIVE_OID: - RelativeOid xiod = new RelativeOid(); - oid = xiod.Decode(new MemoryStream(data)); - oidName = ""; - nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5} [{6}]\r\n", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName, - oidName, - oid - ); - break; - case Asn1Tag.PRINTABLE_STRING: - case Asn1Tag.IA5_STRING: - case Asn1Tag.UNIVERSAL_STRING: - case Asn1Tag.VISIBLE_STRING: - case Asn1Tag.NUMERIC_STRING: - case Asn1Tag.UTC_TIME: - case Asn1Tag.UTF8_STRING: - case Asn1Tag.BMPSTRING: - case Asn1Tag.GENERAL_STRING: - case Asn1Tag.GENERALIZED_TIME: - baseLine = - String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName - ); - if ( tag == Asn1Tag.UTF8_STRING ) - { - UTF8Encoding unicode = new UTF8Encoding(); - dataStr = unicode.GetString(data); - } - else - { - dataStr = Asn1Util.BytesToString(data); - } - if (baseLine.Length + dataStr.Length < lineLen) - { - nodeStr += baseLine + "'" + dataStr + "'\r\n"; - } - else - { - nodeStr += baseLine + FormatLineString( - lStr, - GetIndentStr(startNode).Length, - lineLen, - dataStr) + "\r\n"; - } - break; - case Asn1Tag.INTEGER: - if (data != null && dataLength < 8) - { - nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5}\r\n", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName, - Asn1Util.BytesToLong(data).ToString() - ); - } - else - { - baseLine = - String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName - ); - nodeStr += GetHexPrintingStr(startNode, baseLine, lStr, lineLen); - } - break; - default: - if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate - { - baseLine = - String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName - ); - dataStr = Asn1Util.BytesToString(data); - if (baseLine.Length + dataStr.Length < lineLen) - { - nodeStr += baseLine + "'" + dataStr + "'\r\n"; - } - else - { - nodeStr += baseLine + FormatLineString( - lStr, - GetIndentStr(startNode).Length, - lineLen, - dataStr) + "\r\n"; - } - } - else - { - baseLine = - String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", - dataOffset, - dataLength, - lengthFieldBytes, - GetIndentStr(startNode), - TagName - ); - nodeStr += GetHexPrintingStr(startNode, baseLine, lStr, lineLen); - } - break; - }; - if (childNodeList.Count >= 0) - { - nodeStr += GetListStr(startNode, lineLen); - } - return nodeStr; - } - - /// - /// Get the path string of the node. - /// - public string Path - { - get - { - return path; - } - } - - /// - /// Retrieve the node description. - /// - /// true:Return hex string only; - /// false:Convert to more readable string depending on the node tag. - /// string - public string GetDataStr(bool pureHexMode) - { - const int lineLen = 32; - string dataStr = ""; - if (pureHexMode) - { - dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); - } - else - { - switch (tag) - { - case Asn1Tag.BIT_STRING: - dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); - break; - case Asn1Tag.OBJECT_IDENTIFIER: - Oid xoid = new Oid(); - dataStr = xoid.Decode(new MemoryStream(data)); - break; - case Asn1Tag.RELATIVE_OID: - RelativeOid roid = new RelativeOid(); - dataStr = roid.Decode(new MemoryStream(data)); - break; - case Asn1Tag.PRINTABLE_STRING: - case Asn1Tag.IA5_STRING: - case Asn1Tag.UNIVERSAL_STRING: - case Asn1Tag.VISIBLE_STRING: - case Asn1Tag.NUMERIC_STRING: - case Asn1Tag.UTC_TIME: - case Asn1Tag.BMPSTRING: - case Asn1Tag.GENERAL_STRING: - case Asn1Tag.GENERALIZED_TIME: - dataStr = Asn1Util.BytesToString(data); - break; - case Asn1Tag.UTF8_STRING: - UTF8Encoding utf8 = new UTF8Encoding(); - dataStr = utf8.GetString(data); - break; - case Asn1Tag.INTEGER: - dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); - break; - default: - if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate - { - dataStr = Asn1Util.BytesToString(data); - } - else - { - dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); - } - break; - }; - } - return dataStr; - } - - /// - /// Get node label string. - /// - /// - /// - /// SHOW_OFFSET - /// SHOW_DATA - /// USE_HEX_OFFSET - /// SHOW_TAG_NUMBER - /// SHOW_PATH - /// - /// string - public string GetLabel(uint mask) - { - string nodeStr = ""; - string dataStr = ""; - string offsetStr = ""; - if ((mask & TagTextMask.USE_HEX_OFFSET) != 0) - { - if ((mask & TagTextMask.SHOW_TAG_NUMBER) != 0) - offsetStr = String.Format("(0x{0:X2},0x{1:X6},0x{2:X4})", tag, dataOffset, dataLength); - else - offsetStr = String.Format("(0x{0:X6},0x{1:X4})", dataOffset, dataLength); - } - else - { - if ((mask & TagTextMask.SHOW_TAG_NUMBER) != 0) - offsetStr = String.Format("({0},{1},{2})", tag, dataOffset, dataLength); - else - offsetStr = String.Format("({0},{1})", dataOffset, dataLength); - } - string oid, oidName; - switch (tag) - { - case Asn1Tag.BIT_STRING: - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName + " UnusedBits: " + unusedBits.ToString(); - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - dataStr = Asn1Util.ToHexString(data); - nodeStr += ((dataStr.Length>0) ? " : '" + dataStr + "'" : ""); - } - break; - case Asn1Tag.OBJECT_IDENTIFIER: - Oid xoid = new Oid(); - oid = xoid.Decode(data); - oidName = xoid.GetOidName(oid); - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName; - nodeStr += " : " + oidName; - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - nodeStr += ((oid.Length>0) ? " : '" + oid + "'" : ""); - } - break; - case Asn1Tag.RELATIVE_OID: - RelativeOid roid = new RelativeOid(); - oid = roid.Decode(data); - oidName = ""; - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName; - nodeStr += " : " + oidName; - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - nodeStr += ((oid.Length>0) ? " : '" + oid + "'" : ""); - } - break; - case Asn1Tag.PRINTABLE_STRING: - case Asn1Tag.IA5_STRING: - case Asn1Tag.UNIVERSAL_STRING: - case Asn1Tag.VISIBLE_STRING: - case Asn1Tag.NUMERIC_STRING: - case Asn1Tag.UTC_TIME: - case Asn1Tag.UTF8_STRING: - case Asn1Tag.BMPSTRING: - case Asn1Tag.GENERAL_STRING: - case Asn1Tag.GENERALIZED_TIME: - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName; - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - if ( tag == Asn1Tag.UTF8_STRING ) - { - UTF8Encoding unicode = new UTF8Encoding(); - dataStr = unicode.GetString(data); - } - else - { - dataStr = Asn1Util.BytesToString(data); - } - nodeStr += ((dataStr.Length>0) ? " : '" + dataStr + "'" : ""); - } - break; - case Asn1Tag.INTEGER: - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName; - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - if (data != null && dataLength < 8) - { - dataStr = Asn1Util.BytesToLong(data).ToString(); - } - else - { - dataStr = Asn1Util.ToHexString(data); - } - nodeStr += ((dataStr.Length>0) ? " : '" + dataStr + "'" : ""); - } - break; - default: - if ((mask & TagTextMask.SHOW_OFFSET) != 0) - { - nodeStr += offsetStr; - } - nodeStr += " " + TagName; - if ((mask & TagTextMask.SHOW_DATA) != 0) - { - if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate - { - dataStr = Asn1Util.BytesToString(data); - } - else - { - dataStr = Asn1Util.ToHexString(data); - } - nodeStr += ((dataStr.Length>0) ? " : '" + dataStr + "'" : ""); - } - break; - }; - if ((mask & TagTextMask.SHOW_PATH) != 0) - { - nodeStr = "(" + path + ") " + nodeStr; - } - return nodeStr; - } - - /// - /// Get data length. Not included the unused bits byte for BITSTRING. - /// - public long DataLength - { - get - { - return dataLength; - } - } - - /// - /// Get the length field bytes. - /// - public long LengthFieldBytes - { - get - { - return lengthFieldBytes; - } - } - - /// - /// Get/Set node data by byte[], the data length field content and all the - /// node in the parent chain will be adjusted. - ///

- /// It return all the child data for constructed node. - ///
- public byte[] Data - { - get - { - MemoryStream xdata = new MemoryStream(); - long nodeCount = ChildNodeCount; - if (nodeCount==0) - { - if (data != null) - { - xdata.Write(data, 0, data.Length); - } - } - else - { - Asn1Node tempNode; - for (int i=0; i - /// Get the deepness of the node. - /// - public long Deepness - { - get - { - return deepness; - } - } - - /// - /// Get data offset. - /// - public long DataOffset - { - get - { - return dataOffset; - } - } - - /// - /// Get unused bits for BITSTRING. - /// - public byte UnusedBits - { - get - { - return unusedBits; - } - set - { - unusedBits = value; - } - } - - - /// - /// Get descendant node by node path. - /// - /// relative node path that refer to current node. - /// - public Asn1Node GetDescendantNodeByPath(string nodePath) - { - Asn1Node retval = this; - if (nodePath == null) return retval; - nodePath = nodePath.TrimEnd().TrimStart(); - if (nodePath.Length<1) return retval; - string[] route = nodePath.Split('/'); - try - { - for (int i = 1; i - /// Get node by OID. - /// - /// OID. - /// Starting node. - /// Null or Asn1Node. - static public Asn1Node GetDecendantNodeByOid(string oid, Asn1Node startNode) - { - Asn1Node retval = null; - Oid xoid = new Oid(); - for (int i = 0; i - /// Constant of tag field length. - /// - public const int TagLength = 1; - - /// - /// Constant of unused bits field length. - /// - public const int BitStringUnusedFiledLength = 1; - - /// - /// Tag text generation mask definition. - /// - public class TagTextMask - { - /// - /// Show offset. - /// - public const uint SHOW_OFFSET = 0x01; - - /// - /// Show decoded data. - /// - public const uint SHOW_DATA = 0x02; - - /// - /// Show offset in hex format. - /// - public const uint USE_HEX_OFFSET = 0x04; - - /// - /// Show tag. - /// - public const uint SHOW_TAG_NUMBER = 0x08; - - /// - /// Show node path. - /// - public const uint SHOW_PATH = 0x10; - } - - /// - /// Set/Get requireRecalculatePar. RecalculateTreePar function will not do anything - /// if it is set to false. - /// - protected bool RequireRecalculatePar - { - get - { - return requireRecalculatePar; - } - set - { - requireRecalculatePar = value; - } - } - - //ProtectedMembers - - /// - /// Find root node and recalculate entire tree length field, - /// path, offset and deepness. - /// - protected void RecalculateTreePar() - { - if (!requireRecalculatePar) return; - Asn1Node rootNode; - for (rootNode = this; rootNode.ParentNode != null;) - { - rootNode = rootNode.ParentNode; - } - ResetBranchDataLength(rootNode); - rootNode.dataOffset = 0; - rootNode.deepness = 0; - long subOffset = rootNode.dataOffset + TagLength + rootNode.lengthFieldBytes; - ResetChildNodePar(rootNode, subOffset); - } - - /// - /// Recursively set all the node data length. - /// - /// - /// node data length. - protected static long ResetBranchDataLength(Asn1Node node) - { - long retval = 0; - long childDataLength = 0; - if (node.ChildNodeCount < 1) - { - if (node.data != null) - childDataLength += node.data.Length; - } - else - { - for (int i=0; i - /// Encode the node data length field and set lengthFieldBytes and dataLength. - /// - /// The node needs to be reset. - protected static void ResetDataLengthFieldWidth(Asn1Node node) - { - MemoryStream tempStream = new MemoryStream(); - Asn1Util.DERLengthEncode(tempStream, (ulong) node.dataLength); - node.lengthFieldBytes = tempStream.Length; - tempStream.Close(); - } - - /// - /// Recursively set all the child parameters, except dataLength. - /// dataLength is set by ResetBranchDataLength. - /// - /// Starting node. - /// Starting node offset. - protected void ResetChildNodePar(Asn1Node xNode, long subOffset) - { - int i; - if (xNode.tag == Asn1Tag.BIT_STRING) - { - subOffset++; - } - Asn1Node tempNode; - for (i=0; i - /// Generate all the child text from childNodeList. - /// - /// Starting node. - /// Line length. - /// Text string. - protected string GetListStr(Asn1Node startNode, int lineLen) - { - string nodeStr = ""; - int i; - Asn1Node tempNode; - for (i=0; i - /// Generate the node indent string. - /// - /// The node. - /// Text string. - protected string GetIndentStr(Asn1Node startNode) - { - string retval = ""; - long startLen = 0; - if (startNode!=null) - { - startLen = startNode.Deepness; - } - for (long i = 0; i - /// Decode ASN.1 encoded node Stream data. - /// - /// Stream data. - /// true:Succeed, false:Failed. - protected bool GeneralDecode(Stream xdata) - { - long nodeMaxLen = xdata.Length - xdata.Position; - tag = (byte) xdata.ReadByte(); - long start = xdata.Position; - dataLength = Asn1Util.DerLengthDecode(xdata, ref isIndefiniteLength); + /// Remove the child from children node list. + /// + /// The node needs to be removed. + /// + public Asn1Node RemoveChild(Asn1Node node) + { + Asn1Node retval = null; + int i = childNodeList.IndexOf(node); + retval = RemoveChild(i); + return retval; + } + + /// + /// Get child node count. + /// + public long ChildNodeCount + { + get + { + return childNodeList.Count; + } + } + + /// + /// Retrieve child node by index. + /// + /// 0 based index. + /// 0 based index. + public Asn1Node GetChildNode(int index) + { + Asn1Node retval = null; + if (index < ChildNodeCount) + { + retval = (Asn1Node)childNodeList[index]; + } + return retval; + } + + + + /// + /// Get tag name. + /// + public string TagName + { + get + { + return Asn1Util.GetTagName(tag); + } + } + + /// + /// Get parent node. + /// + public Asn1Node ParentNode + { + get + { + return parentNode; + } + } + + /// + /// Get the node and all the descendents text description. + /// + /// starting node. + /// line length. + /// + public string GetText(Asn1Node startNode, int lineLen) + { + string nodeStr = ""; + string baseLine = ""; + string dataStr = ""; + const string lStr = " | | | "; + string oid, oidName; + switch (tag) + { + case Asn1Tag.BIT_STRING: + baseLine = + String.Format("{0,6}|{1,6}|{2,7}|{3} {4} UnusedBits:{5} : ", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName, + unusedBits + ); + dataStr = Asn1Util.ToHexString(data); + if (baseLine.Length + dataStr.Length < lineLen) + { + if (dataStr.Length < 1) + { + nodeStr += baseLine + "\r\n"; + } + else + { + nodeStr += baseLine + "'" + dataStr + "'\r\n"; + } + } + else + { + nodeStr += baseLine + FormatLineHexString( + lStr, + GetIndentStr(startNode).Length, + lineLen, + dataStr + "\r\n" + ); + } + break; + case Asn1Tag.OBJECT_IDENTIFIER: + Oid xoid = new Oid(); + oid = xoid.Decode(new MemoryStream(data)); + oidName = xoid.GetOidName(oid); + nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5} [{6}]\r\n", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName, + oidName, + oid + ); + break; + case Asn1Tag.RELATIVE_OID: + RelativeOid xiod = new RelativeOid(); + oid = xiod.Decode(new MemoryStream(data)); + oidName = ""; + nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5} [{6}]\r\n", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName, + oidName, + oid + ); + break; + case Asn1Tag.PRINTABLE_STRING: + case Asn1Tag.IA5_STRING: + case Asn1Tag.UNIVERSAL_STRING: + case Asn1Tag.VISIBLE_STRING: + case Asn1Tag.NUMERIC_STRING: + case Asn1Tag.UTC_TIME: + case Asn1Tag.UTF8_STRING: + case Asn1Tag.BMPSTRING: + case Asn1Tag.GENERAL_STRING: + case Asn1Tag.GENERALIZED_TIME: + baseLine = + String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName + ); + if (tag == Asn1Tag.UTF8_STRING) + { + UTF8Encoding unicode = new UTF8Encoding(); + dataStr = unicode.GetString(data); + } + else + { + dataStr = Asn1Util.BytesToString(data); + } + if (baseLine.Length + dataStr.Length < lineLen) + { + nodeStr += baseLine + "'" + dataStr + "'\r\n"; + } + else + { + nodeStr += baseLine + FormatLineString( + lStr, + GetIndentStr(startNode).Length, + lineLen, + dataStr) + "\r\n"; + } + break; + case Asn1Tag.INTEGER: + if (data != null && dataLength < 8) + { + nodeStr += String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : {5}\r\n", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName, + Asn1Util.BytesToLong(data).ToString() + ); + } + else + { + baseLine = + String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName + ); + nodeStr += GetHexPrintingStr(startNode, baseLine, lStr, lineLen); + } + break; + default: + if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate + { + baseLine = + String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName + ); + dataStr = Asn1Util.BytesToString(data); + if (baseLine.Length + dataStr.Length < lineLen) + { + nodeStr += baseLine + "'" + dataStr + "'\r\n"; + } + else + { + nodeStr += baseLine + FormatLineString( + lStr, + GetIndentStr(startNode).Length, + lineLen, + dataStr) + "\r\n"; + } + } + else + { + baseLine = + String.Format("{0,6}|{1,6}|{2,7}|{3} {4} : ", + dataOffset, + dataLength, + lengthFieldBytes, + GetIndentStr(startNode), + TagName + ); + nodeStr += GetHexPrintingStr(startNode, baseLine, lStr, lineLen); + } + break; + }; + if (childNodeList.Count >= 0) + { + nodeStr += GetListStr(startNode, lineLen); + } + return nodeStr; + } + + /// + /// Get the path string of the node. + /// + public string Path + { + get + { + return path; + } + } + + /// + /// Retrieve the node description. + /// + /// true:Return hex string only; + /// false:Convert to more readable string depending on the node tag. + /// string + public string GetDataStr(bool pureHexMode) + { + const int lineLen = 32; + string dataStr = ""; + if (pureHexMode) + { + dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); + } + else + { + switch (tag) + { + case Asn1Tag.BIT_STRING: + dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); + break; + case Asn1Tag.OBJECT_IDENTIFIER: + Oid xoid = new Oid(); + dataStr = xoid.Decode(new MemoryStream(data)); + break; + case Asn1Tag.RELATIVE_OID: + RelativeOid roid = new RelativeOid(); + dataStr = roid.Decode(new MemoryStream(data)); + break; + case Asn1Tag.PRINTABLE_STRING: + case Asn1Tag.IA5_STRING: + case Asn1Tag.UNIVERSAL_STRING: + case Asn1Tag.VISIBLE_STRING: + case Asn1Tag.NUMERIC_STRING: + case Asn1Tag.UTC_TIME: + case Asn1Tag.BMPSTRING: + case Asn1Tag.GENERAL_STRING: + case Asn1Tag.GENERALIZED_TIME: + dataStr = Asn1Util.BytesToString(data); + break; + case Asn1Tag.UTF8_STRING: + UTF8Encoding utf8 = new UTF8Encoding(); + dataStr = utf8.GetString(data); + break; + case Asn1Tag.INTEGER: + dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); + break; + default: + if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate + { + dataStr = Asn1Util.BytesToString(data); + } + else + { + dataStr = Asn1Util.FormatString(Asn1Util.ToHexString(data), lineLen, 2); + } + break; + }; + } + return dataStr; + } + + /// + /// Get node label string. + /// + /// + /// + /// SHOW_OFFSET + /// SHOW_DATA + /// USE_HEX_OFFSET + /// SHOW_TAG_NUMBER + /// SHOW_PATH + /// + /// string + public string GetLabel(uint mask) + { + string nodeStr = ""; + string dataStr = ""; + string offsetStr = ""; + if ((mask & TagTextMask.USE_HEX_OFFSET) != 0) + { + if ((mask & TagTextMask.SHOW_TAG_NUMBER) != 0) + offsetStr = String.Format("(0x{0:X2},0x{1:X6},0x{2:X4})", tag, dataOffset, dataLength); + else + offsetStr = String.Format("(0x{0:X6},0x{1:X4})", dataOffset, dataLength); + } + else + { + if ((mask & TagTextMask.SHOW_TAG_NUMBER) != 0) + offsetStr = String.Format("({0},{1},{2})", tag, dataOffset, dataLength); + else + offsetStr = String.Format("({0},{1})", dataOffset, dataLength); + } + string oid, oidName; + switch (tag) + { + case Asn1Tag.BIT_STRING: + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName + " UnusedBits: " + unusedBits.ToString(); + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + dataStr = Asn1Util.ToHexString(data); + nodeStr += ((dataStr.Length > 0) ? " : '" + dataStr + "'" : ""); + } + break; + case Asn1Tag.OBJECT_IDENTIFIER: + Oid xoid = new Oid(); + oid = xoid.Decode(data); + oidName = xoid.GetOidName(oid); + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName; + nodeStr += " : " + oidName; + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + nodeStr += ((oid.Length > 0) ? " : '" + oid + "'" : ""); + } + break; + case Asn1Tag.RELATIVE_OID: + RelativeOid roid = new RelativeOid(); + oid = roid.Decode(data); + oidName = ""; + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName; + nodeStr += " : " + oidName; + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + nodeStr += ((oid.Length > 0) ? " : '" + oid + "'" : ""); + } + break; + case Asn1Tag.PRINTABLE_STRING: + case Asn1Tag.IA5_STRING: + case Asn1Tag.UNIVERSAL_STRING: + case Asn1Tag.VISIBLE_STRING: + case Asn1Tag.NUMERIC_STRING: + case Asn1Tag.UTC_TIME: + case Asn1Tag.UTF8_STRING: + case Asn1Tag.BMPSTRING: + case Asn1Tag.GENERAL_STRING: + case Asn1Tag.GENERALIZED_TIME: + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName; + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + if (tag == Asn1Tag.UTF8_STRING) + { + UTF8Encoding unicode = new UTF8Encoding(); + dataStr = unicode.GetString(data); + } + else + { + dataStr = Asn1Util.BytesToString(data); + } + nodeStr += ((dataStr.Length > 0) ? " : '" + dataStr + "'" : ""); + } + break; + case Asn1Tag.INTEGER: + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName; + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + if (data != null && dataLength < 8) + { + dataStr = Asn1Util.BytesToLong(data).ToString(); + } + else + { + dataStr = Asn1Util.ToHexString(data); + } + nodeStr += ((dataStr.Length > 0) ? " : '" + dataStr + "'" : ""); + } + break; + default: + if ((mask & TagTextMask.SHOW_OFFSET) != 0) + { + nodeStr += offsetStr; + } + nodeStr += " " + TagName; + if ((mask & TagTextMask.SHOW_DATA) != 0) + { + if ((tag & Asn1Tag.TAG_MASK) == 6) // Visible string for certificate + { + dataStr = Asn1Util.BytesToString(data); + } + else + { + dataStr = Asn1Util.ToHexString(data); + } + nodeStr += ((dataStr.Length > 0) ? " : '" + dataStr + "'" : ""); + } + break; + }; + if ((mask & TagTextMask.SHOW_PATH) != 0) + { + nodeStr = "(" + path + ") " + nodeStr; + } + return nodeStr; + } + + /// + /// Get data length. Not included the unused bits byte for BITSTRING. + /// + public long DataLength + { + get + { + return dataLength; + } + } + + /// + /// Get the length field bytes. + /// + public long LengthFieldBytes + { + get + { + return lengthFieldBytes; + } + } + + /// + /// Get/Set node data by byte[], the data length field content and all the + /// node in the parent chain will be adjusted. + ///

+ /// It return all the child data for constructed node. + ///
+ public byte[] Data + { + get + { + MemoryStream xdata = new MemoryStream(); + long nodeCount = ChildNodeCount; + if (nodeCount == 0) + { + if (data != null) + { + xdata.Write(data, 0, data.Length); + } + } + else + { + Asn1Node tempNode; + for (int i = 0; i < nodeCount; i++) + { + tempNode = GetChildNode(i); + tempNode.SaveData(xdata); + } + } + byte[] tmpData = new byte[xdata.Length]; + xdata.Position = 0; + xdata.Read(tmpData, 0, (int)xdata.Length); + xdata.Close(); + return tmpData; + } + set + { + SetData(value); + } + } + + /// + /// Get the deepness of the node. + /// + public long Deepness + { + get + { + return deepness; + } + } + + /// + /// Get data offset. + /// + public long DataOffset + { + get + { + return dataOffset; + } + } + + /// + /// Get unused bits for BITSTRING. + /// + public byte UnusedBits + { + get + { + return unusedBits; + } + set + { + unusedBits = value; + } + } + + + /// + /// Get descendant node by node path. + /// + /// relative node path that refer to current node. + /// + public Asn1Node GetDescendantNodeByPath(string nodePath) + { + Asn1Node retval = this; + if (nodePath == null) return retval; + nodePath = nodePath.TrimEnd().TrimStart(); + if (nodePath.Length < 1) return retval; + string[] route = nodePath.Split('/'); + try + { + for (int i = 1; i < route.Length; i++) + { + retval = retval.GetChildNode(Convert.ToInt32(route[i])); + } + } + catch + { + retval = null; + } + return retval; + } + + /// + /// Get node by OID. + /// + /// OID. + /// Starting node. + /// Null or Asn1Node. + static public Asn1Node GetDecendantNodeByOid(string oid, Asn1Node startNode) + { + Asn1Node retval = null; + Oid xoid = new Oid(); + for (int i = 0; i < startNode.ChildNodeCount; i++) + { + Asn1Node childNode = startNode.GetChildNode(i); + int tmpTag = childNode.tag & Asn1Tag.TAG_MASK; + if (tmpTag == Asn1Tag.OBJECT_IDENTIFIER) + { + if (oid == xoid.Decode(childNode.Data)) + { + retval = childNode; + break; + } + } + retval = GetDecendantNodeByOid(oid, childNode); + if (retval != null) break; + } + return retval; + } + + /// + /// Constant of tag field length. + /// + public const int TagLength = 1; + + /// + /// Constant of unused bits field length. + /// + public const int BitStringUnusedFiledLength = 1; + + /// + /// Tag text generation mask definition. + /// + public class TagTextMask + { + /// + /// Show offset. + /// + public const uint SHOW_OFFSET = 0x01; + + /// + /// Show decoded data. + /// + public const uint SHOW_DATA = 0x02; + + /// + /// Show offset in hex format. + /// + public const uint USE_HEX_OFFSET = 0x04; + + /// + /// Show tag. + /// + public const uint SHOW_TAG_NUMBER = 0x08; + + /// + /// Show node path. + /// + public const uint SHOW_PATH = 0x10; + } + + /// + /// Set/Get requireRecalculatePar. RecalculateTreePar function will not do anything + /// if it is set to false. + /// + protected bool RequireRecalculatePar + { + get + { + return requireRecalculatePar; + } + set + { + requireRecalculatePar = value; + } + } + + //ProtectedMembers + + /// + /// Find root node and recalculate entire tree length field, + /// path, offset and deepness. + /// + protected void RecalculateTreePar() + { + if (!requireRecalculatePar) return; + Asn1Node rootNode; + for (rootNode = this; rootNode.ParentNode != null;) + { + rootNode = rootNode.ParentNode; + } + ResetBranchDataLength(rootNode); + rootNode.dataOffset = 0; + rootNode.deepness = 0; + long subOffset = rootNode.dataOffset + TagLength + rootNode.lengthFieldBytes; + ResetChildNodePar(rootNode, subOffset); + } + + /// + /// Recursively set all the node data length. + /// + /// + /// node data length. + protected static long ResetBranchDataLength(Asn1Node node) + { + long retval = 0; + long childDataLength = 0; + if (node.ChildNodeCount < 1) + { + if (node.data != null) + childDataLength += node.data.Length; + } + else + { + for (int i = 0; i < node.ChildNodeCount; i++) + { + childDataLength += ResetBranchDataLength(node.GetChildNode(i)); + } + } + node.dataLength = childDataLength; + if (node.tag == Asn1Tag.BIT_STRING) + node.dataLength += BitStringUnusedFiledLength; + ResetDataLengthFieldWidth(node); + retval = node.dataLength + TagLength + node.lengthFieldBytes; + return retval; + } + + /// + /// Encode the node data length field and set lengthFieldBytes and dataLength. + /// + /// The node needs to be reset. + protected static void ResetDataLengthFieldWidth(Asn1Node node) + { + MemoryStream tempStream = new MemoryStream(); + Asn1Util.DERLengthEncode(tempStream, (ulong)node.dataLength); + node.lengthFieldBytes = tempStream.Length; + tempStream.Close(); + } + + /// + /// Recursively set all the child parameters, except dataLength. + /// dataLength is set by ResetBranchDataLength. + /// + /// Starting node. + /// Starting node offset. + protected void ResetChildNodePar(Asn1Node xNode, long subOffset) + { + int i; + if (xNode.tag == Asn1Tag.BIT_STRING) + { + subOffset++; + } + Asn1Node tempNode; + for (i = 0; i < xNode.ChildNodeCount; i++) + { + tempNode = xNode.GetChildNode(i); + tempNode.parentNode = xNode; + tempNode.dataOffset = subOffset; + tempNode.deepness = xNode.deepness + 1; + tempNode.path = xNode.path + '/' + i.ToString(); + subOffset += TagLength + tempNode.lengthFieldBytes; + ResetChildNodePar(tempNode, subOffset); + subOffset += tempNode.dataLength; + } + } + + /// + /// Generate all the child text from childNodeList. + /// + /// Starting node. + /// Line length. + /// Text string. + protected string GetListStr(Asn1Node startNode, int lineLen) + { + string nodeStr = ""; + int i; + Asn1Node tempNode; + for (i = 0; i < childNodeList.Count; i++) + { + tempNode = (Asn1Node)childNodeList[i]; + nodeStr += tempNode.GetText(startNode, lineLen); + } + return nodeStr; + } + + /// + /// Generate the node indent string. + /// + /// The node. + /// Text string. + protected string GetIndentStr(Asn1Node startNode) + { + string retval = ""; + long startLen = 0; + if (startNode != null) + { + startLen = startNode.Deepness; + } + for (long i = 0; i < deepness - startLen; i++) + { + retval += " "; + } + return retval; + } + + /// + /// Decode ASN.1 encoded node Stream data. + /// + /// Stream data. + /// true:Succeed, false:Failed. + protected bool GeneralDecode(Stream xdata) + { + long nodeMaxLen = xdata.Length - xdata.Position; + tag = (byte)xdata.ReadByte(); + long start = xdata.Position; + dataLength = Asn1Util.DerLengthDecode(xdata, ref isIndefiniteLength); if (AreTagsOk()) { @@ -1309,7 +1309,7 @@ protected bool GeneralDecode(Stream xdata) { return false; } - } + } private bool AreTagsOk() { @@ -1362,7 +1362,7 @@ private bool GeneralDecodeKnownLength(Stream xdata) return false; } - unusedBits = (byte) xdata.ReadByte(); + unusedBits = (byte)xdata.ReadByte(); ReadStreamDataDefiniteLength(xdata, (int)(dataLength - 1)); } else @@ -1376,14 +1376,14 @@ private bool GeneralDecodeKnownLength(Stream xdata) private void ReadStreamDataDefiniteLength(Stream xdata, int length) { data = new byte[length]; - xdata.Read(data, 0, (int)(length) ); + xdata.Read(data, 0, (int)(length)); } private bool GeneralDecodeIndefiniteLength(Stream xdata, long nodeMaxLen) { if (tag == Asn1Tag.BIT_STRING) { - unusedBits = (byte) xdata.ReadByte(); + unusedBits = (byte)xdata.ReadByte(); nodeMaxLen--; } @@ -1457,20 +1457,20 @@ void ReadMeasuredLengthDataFromStart(Stream xdata, long startPosition, long leng } /// - /// Decode ASN.1 encoded complex data type Stream data. - /// - /// Stream data. - /// true:Succeed, false:Failed. - protected bool ListDecode(Stream xdata) - { - bool retval = false; - long originalPosition = xdata.Position; - - try - { - tag = (byte) xdata.ReadByte(); - long start = xdata.Position; - dataLength = Asn1Util.DerLengthDecode(xdata, ref isIndefiniteLength); + /// Decode ASN.1 encoded complex data type Stream data. + /// + /// Stream data. + /// true:Succeed, false:Failed. + protected bool ListDecode(Stream xdata) + { + bool retval = false; + long originalPosition = xdata.Position; + + try + { + tag = (byte)xdata.ReadByte(); + long start = xdata.Position; + dataLength = Asn1Util.DerLengthDecode(xdata, ref isIndefiniteLength); long childNodeMaxLen = xdata.Length - xdata.Position; @@ -1483,16 +1483,16 @@ protected bool ListDecode(Stream xdata) retval = ListDecodeKnownLengthWithChecks(xdata, start, childNodeMaxLen); } } - finally - { - if (!retval) - { - xdata.Position = originalPosition; - ClearAll(); - } - } - return retval; - } + finally + { + if (!retval) + { + xdata.Position = originalPosition; + ClearAll(); + } + } + return retval; + } private bool ListDecodeKnownLengthWithChecks(Stream xdata, long start, long childNodeMaxLen) { @@ -1536,7 +1536,7 @@ bool HandleBitStringTag(Stream xdata, ref long offset) { // First byte of BIT_STRING is unused bits. // BIT_STRING data does not include this byte. - unusedBits = (byte) xdata.ReadByte(); + unusedBits = (byte)xdata.ReadByte(); dataLength--; offset++; @@ -1558,7 +1558,7 @@ private Stream CreateAndPrepareListDecodeMemoryStreamKnownLength(Stream xdata) Stream secData = new MemoryStream((int)dataLength); byte[] secByte = new byte[dataLength]; - xdata.Read(secByte, 0, (int) (dataLength)); + xdata.Read(secByte, 0, (int)(dataLength)); if (tag == Asn1Tag.BIT_STRING) { @@ -1573,7 +1573,7 @@ private Stream CreateAndPrepareListDecodeMemoryStreamKnownLength(Stream xdata) private bool ListDecodeChildNodesWithKnownLength(Stream secData, long offset) { - while(secData.Position < secData.Length) + while (secData.Position < secData.Length) { if (!CreateAndAddChildNode(secData, ref offset)) { @@ -1617,7 +1617,7 @@ bool ListDecodeIndefiniteLengthInternal(Stream xdata, long offset, long childNod { bool doneReading = false; - while(!doneReading) + while (!doneReading) { var oldOffset = offset; doneReading = ReadNextChildNodeOrEndFooterOfIndefiniteListClearIfInvalid(xdata, ref offset, childNodeMaxLen); @@ -1697,19 +1697,19 @@ bool ReadNextChildNodeOfIndefiniteListClearIfInvalid(Stream xdata, ref long offs return validChildNode; } - /// - /// Set the node data and recalculate the entire tree parameters. - /// - /// byte[] data. - protected void SetData(byte[] xdata) - { - if (childNodeList.Count > 0) - { - throw new Exception("Constructed node can't hold simple data."); - } - else - { - data = xdata; + /// + /// Set the node data and recalculate the entire tree parameters. + /// + /// byte[] data. + protected void SetData(byte[] xdata) + { + if (childNodeList.Count > 0) + { + throw new Exception("Constructed node can't hold simple data."); + } + else + { + data = xdata; if (data != null) { dataLength = data.Length; @@ -1720,22 +1720,22 @@ protected void SetData(byte[] xdata) } RecalculateTreePar(); - } - } - - /// - /// Load data from Stream. Start from current position. - /// - /// Stream - /// true:Succeed; false:failed. - protected bool InternalLoadData(Stream xdata) - { - bool retval = true; - ClearAll(); + } + } + + /// + /// Load data from Stream. Start from current position. + /// + /// Stream + /// true:Succeed; false:failed. + protected bool InternalLoadData(Stream xdata) + { + bool retval = true; + ClearAll(); byte xtag; long curPosition = xdata.Position; - xtag = (byte) xdata.ReadByte(); + xtag = (byte)xdata.ReadByte(); xdata.Position = curPosition; int maskedTag = xtag & Asn1Tag.TAG_MASK; @@ -1771,52 +1771,52 @@ protected bool InternalLoadData(Stream xdata) if (!GeneralDecode(xdata)) retval = false; } - return retval; - } - - /// - /// Get/Set parseEncapsulatedData. This property will be inherited by the - /// child nodes when loading data. - /// - public bool ParseEncapsulatedData - { - get - { - return parseEncapsulatedData; - } - set - { - if (parseEncapsulatedData == value) return; - byte[] tmpData = Data; - parseEncapsulatedData = value; - ClearAll(); - if ((tag & Asn1TagClasses.CONSTRUCTED) != 0 || parseEncapsulatedData) - { - MemoryStream ms = new MemoryStream(tmpData); - ms.Position = 0; - bool isLoaded = true; - while(ms.Position < ms.Length) - { - Asn1Node tempNode = new Asn1Node(); - tempNode.ParseEncapsulatedData = parseEncapsulatedData; - if (!tempNode.LoadData(ms)) - { - ClearAll(); - isLoaded = false; - break; - } - AddChild(tempNode); - } - if (!isLoaded) - { - Data = tmpData; - } - } - else - { - Data = tmpData; - } - } - } - } + return retval; + } + + /// + /// Get/Set parseEncapsulatedData. This property will be inherited by the + /// child nodes when loading data. + /// + public bool ParseEncapsulatedData + { + get + { + return parseEncapsulatedData; + } + set + { + if (parseEncapsulatedData == value) return; + byte[] tmpData = Data; + parseEncapsulatedData = value; + ClearAll(); + if ((tag & Asn1TagClasses.CONSTRUCTED) != 0 || parseEncapsulatedData) + { + MemoryStream ms = new MemoryStream(tmpData); + ms.Position = 0; + bool isLoaded = true; + while (ms.Position < ms.Length) + { + Asn1Node tempNode = new Asn1Node(); + tempNode.ParseEncapsulatedData = parseEncapsulatedData; + if (!tempNode.LoadData(ms)) + { + ClearAll(); + isLoaded = false; + break; + } + AddChild(tempNode); + } + if (!isLoaded) + { + Data = tmpData; + } + } + else + { + Data = tmpData; + } + } + } + } } diff --git a/Runtime/Security/Asn1Processor/Asn1Parser.cs b/Runtime/Security/Asn1Processor/Asn1Parser.cs index 24f2535..7fd82f8 100644 --- a/Runtime/Security/Asn1Processor/Asn1Parser.cs +++ b/Runtime/Security/Asn1Processor/Asn1Parser.cs @@ -29,180 +29,178 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// ASN.1 encoded data parser. - /// This a higher level class which unilized Asn1Node class functionality to - /// provide functions for ASN.1 encoded files. - /// - internal class Asn1Parser - { - private byte[] rawData; - private Asn1Node rootNode = new Asn1Node(); - - /// - /// Get/Set parseEncapsulatedData. Reloading data is required after this property is reset. - /// - bool ParseEncapsulatedData - { - get - { - return rootNode.ParseEncapsulatedData; - } - set - { - rootNode.ParseEncapsulatedData = value; - } - } - - /// - /// Constructor. - /// - public Asn1Parser() - { - } - - /// - /// Get raw ASN.1 encoded data. - /// - public byte[] RawData - { - get - { - return rawData; - } - } - - /// - /// Load ASN.1 encoded data from a file. - /// - /// File name. - public void LoadData(string fileName) - { - FileStream fs = new FileStream(fileName, FileMode.Open); - rawData = new byte[fs.Length]; - fs.Read(rawData, 0, (int)fs.Length); - fs.Close(); - MemoryStream ms = new MemoryStream(rawData); - LoadData(ms); - } - - /// - /// Load PEM formated file. - /// - /// PEM file name. - public void LoadPemData(string fileName) - { - FileStream fs = new FileStream(fileName, FileMode.Open); - byte[] data = new byte[fs.Length]; - fs.Read(data, 0, data.Length); - fs.Close(); - string dataStr = Asn1Util.BytesToString(data); - if (Asn1Util.IsPemFormated(dataStr)) - { - Stream ms = Asn1Util.PemToStream(dataStr); - ms.Position = 0; - LoadData(ms); - } - else - { - throw new Exception("It is a invalid PEM file: " + fileName); - } - } - - /// - /// Load ASN.1 encoded data from Stream. - /// - /// Stream data. - public void LoadData(Stream stream) - { - stream.Position = 0; - if (!rootNode.LoadData(stream)) - { - throw new ArgumentException("Failed to load data."); - } - rawData = new byte[stream.Length]; - stream.Position = 0; - stream.Read(rawData, 0, rawData.Length); - } - - /// - /// Save data into a file. - /// - /// File name. - public void SaveData(string fileName) - { - FileStream fs = new FileStream(fileName, FileMode.Create); - rootNode.SaveData(fs); - fs.Close(); - } - - /// - /// Get root node. - /// - public Asn1Node RootNode - { - get - { - return rootNode; - } - } - - /// - /// Get a node by path string. - /// - /// Path string. - /// Asn1Node or null. - public Asn1Node GetNodeByPath(string nodePath) - { - return rootNode.GetDescendantNodeByPath(nodePath); - } - - /// - /// Get a node by OID. - /// - /// OID string. - /// Asn1Node or null. - public Asn1Node GetNodeByOid(string oid) - { - return Asn1Node.GetDecendantNodeByOid(oid, rootNode); - } - - /// - /// Generate node text header. This method is used by GetNodeText to put heading. - /// - /// Line length. - /// Header string. - static public string GetNodeTextHeader(int lineLen) - { - string header = String.Format("Offset| Len |LenByte|\r\n"); - header += "======+======+=======+" + Asn1Util.GenStr(lineLen+10, '=') + "\r\n"; - return header; - } - - /// - /// Generate the root node text description. - /// - /// Text string. - public override string ToString() - { - return GetNodeText(rootNode, 100); - } - - /// - /// Generate node text description. It uses GetNodeTextHeader to generate - /// the heading and Asn1Node.GetText to generate the node text. - /// - /// Target node. - /// Line length. - /// Text string. - public static string GetNodeText(Asn1Node node, int lineLen) - { - string nodeStr = GetNodeTextHeader(lineLen); - nodeStr +=node.GetText(node, lineLen); - return nodeStr; - } - - } + /// + /// ASN.1 encoded data parser. + /// This a higher level class which unilized Asn1Node class functionality to + /// provide functions for ASN.1 encoded files. + /// + internal class Asn1Parser + { + private byte[] rawData; + private Asn1Node rootNode = new Asn1Node(); + + /// + /// Get/Set parseEncapsulatedData. Reloading data is required after this property is reset. + /// + bool ParseEncapsulatedData + { + get + { + return rootNode.ParseEncapsulatedData; + } + set + { + rootNode.ParseEncapsulatedData = value; + } + } + + /// + /// Constructor. + /// + public Asn1Parser() + { + } + + /// + /// Get raw ASN.1 encoded data. + /// + public byte[] RawData + { + get + { + return rawData; + } + } + + /// + /// Load ASN.1 encoded data from a file. + /// + /// File name. + public void LoadData(string fileName) + { + FileStream fs = new FileStream(fileName, FileMode.Open); + rawData = new byte[fs.Length]; + fs.Read(rawData, 0, (int)fs.Length); + fs.Close(); + MemoryStream ms = new MemoryStream(rawData); + LoadData(ms); + } + + /// + /// Load PEM formated file. + /// + /// PEM file name. + public void LoadPemData(string fileName) + { + FileStream fs = new FileStream(fileName, FileMode.Open); + byte[] data = new byte[fs.Length]; + fs.Read(data, 0, data.Length); + fs.Close(); + string dataStr = Asn1Util.BytesToString(data); + if (Asn1Util.IsPemFormated(dataStr)) + { + Stream ms = Asn1Util.PemToStream(dataStr); + ms.Position = 0; + LoadData(ms); + } + else + { + throw new Exception("It is a invalid PEM file: " + fileName); + } + } + + /// + /// Load ASN.1 encoded data from Stream. + /// + /// Stream data. + public void LoadData(Stream stream) + { + stream.Position = 0; + if (!rootNode.LoadData(stream)) + { + throw new ArgumentException("Failed to load data."); + } + rawData = new byte[stream.Length]; + stream.Position = 0; + stream.Read(rawData, 0, rawData.Length); + } + + /// + /// Save data into a file. + /// + /// File name. + public void SaveData(string fileName) + { + FileStream fs = new FileStream(fileName, FileMode.Create); + rootNode.SaveData(fs); + fs.Close(); + } + + /// + /// Get root node. + /// + public Asn1Node RootNode + { + get + { + return rootNode; + } + } + + /// + /// Get a node by path string. + /// + /// Path string. + /// Asn1Node or null. + public Asn1Node GetNodeByPath(string nodePath) + { + return rootNode.GetDescendantNodeByPath(nodePath); + } + + /// + /// Get a node by OID. + /// + /// OID string. + /// Asn1Node or null. + public Asn1Node GetNodeByOid(string oid) + { + return Asn1Node.GetDecendantNodeByOid(oid, rootNode); + } + + /// + /// Generate node text header. This method is used by GetNodeText to put heading. + /// + /// Line length. + /// Header string. + static public string GetNodeTextHeader(int lineLen) + { + string header = String.Format("Offset| Len |LenByte|\r\n"); + header += "======+======+=======+" + Asn1Util.GenStr(lineLen + 10, '=') + "\r\n"; + return header; + } + + /// + /// Generate the root node text description. + /// + /// Text string. + public override string ToString() + { + return GetNodeText(rootNode, 100); + } + + /// + /// Generate node text description. It uses GetNodeTextHeader to generate + /// the heading and Asn1Node.GetText to generate the node text. + /// + /// Target node. + /// Line length. + /// Text string. + public static string GetNodeText(Asn1Node node, int lineLen) + { + string nodeStr = GetNodeTextHeader(lineLen); + nodeStr += node.GetText(node, lineLen); + return nodeStr; + } + + } } - - diff --git a/Runtime/Security/Asn1Processor/Asn1Tag.cs b/Runtime/Security/Asn1Processor/Asn1Tag.cs index 538d2c7..2ed0ed8 100644 --- a/Runtime/Security/Asn1Processor/Asn1Tag.cs +++ b/Runtime/Security/Asn1Processor/Asn1Tag.cs @@ -37,142 +37,142 @@ internal class Asn1Tag /// /// Tag mask constant value. /// - public const byte TAG_MASK = 0x1F; + public const byte TAG_MASK = 0x1F; /// /// Constant value. /// - public const byte TAG_END_OF_CONTENTS = 0x00; + public const byte TAG_END_OF_CONTENTS = 0x00; /// /// Constant value. /// - public const byte BOOLEAN = 0x01; + public const byte BOOLEAN = 0x01; /// /// Constant value. /// - public const byte INTEGER = 0x02; + public const byte INTEGER = 0x02; /// /// Constant value. /// - public const byte BIT_STRING = 0x03; + public const byte BIT_STRING = 0x03; /// /// Constant value. /// - public const byte OCTET_STRING = 0x04; + public const byte OCTET_STRING = 0x04; /// /// Constant value. /// - public const byte TAG_NULL = 0x05; + public const byte TAG_NULL = 0x05; /// /// Constant value. /// - public const byte OBJECT_IDENTIFIER = 0x06; + public const byte OBJECT_IDENTIFIER = 0x06; /// /// Constant value. /// - public const byte OBJECT_DESCRIPTOR = 0x07; + public const byte OBJECT_DESCRIPTOR = 0x07; /// /// Constant value. /// - public const byte EXTERNAL = 0x08; + public const byte EXTERNAL = 0x08; /// /// Constant value. /// - public const byte REAL = 0x09; + public const byte REAL = 0x09; /// /// Constant value. /// - public const byte ENUMERATED = 0x0a; + public const byte ENUMERATED = 0x0a; /// /// Constant value. /// - public const byte UTF8_STRING = 0x0c; + public const byte UTF8_STRING = 0x0c; /// /// Relative object identifier. /// - public const byte RELATIVE_OID = 0x0d; + public const byte RELATIVE_OID = 0x0d; /// /// Constant value. /// - public const byte SEQUENCE = 0x10; + public const byte SEQUENCE = 0x10; /// /// Constant value. /// - public const byte SET = 0x11; + public const byte SET = 0x11; /// /// Constant value. /// - public const byte NUMERIC_STRING = 0x12; + public const byte NUMERIC_STRING = 0x12; /// /// Constant value. /// - public const byte PRINTABLE_STRING = 0x13; + public const byte PRINTABLE_STRING = 0x13; /// /// Constant value. /// - public const byte T61_STRING = 0x14; + public const byte T61_STRING = 0x14; /// /// Constant value. /// - public const byte VIDEOTEXT_STRING = 0x15; + public const byte VIDEOTEXT_STRING = 0x15; /// /// Constant value. /// - public const byte IA5_STRING = 0x16; + public const byte IA5_STRING = 0x16; /// /// Constant value. /// - public const byte UTC_TIME = 0x17; + public const byte UTC_TIME = 0x17; /// /// Constant value. /// - public const byte GENERALIZED_TIME = 0x18; + public const byte GENERALIZED_TIME = 0x18; /// /// Constant value. /// - public const byte GRAPHIC_STRING = 0x19; + public const byte GRAPHIC_STRING = 0x19; /// /// Constant value. /// - public const byte VISIBLE_STRING = 0x1a; + public const byte VISIBLE_STRING = 0x1a; /// /// Constant value. /// - public const byte GENERAL_STRING = 0x1b; + public const byte GENERAL_STRING = 0x1b; /// /// Constant value. - /// - public const byte UNIVERSAL_STRING = 0x1C; + /// + public const byte UNIVERSAL_STRING = 0x1C; /// - /// Constant value. + /// Constant value. /// - public const byte BMPSTRING = 0x1E; /* 30: Basic Multilingual Plane/Unicode string */ + public const byte BMPSTRING = 0x1E; /* 30: Basic Multilingual Plane/Unicode string */ /// /// Constructor. @@ -191,32 +191,32 @@ internal class Asn1TagClasses /// /// Constant value. /// - public const byte CLASS_MASK = 0xc0; + public const byte CLASS_MASK = 0xc0; /// /// Constant value. /// - public const byte UNIVERSAL = 0x00; + public const byte UNIVERSAL = 0x00; /// /// Constant value. /// - public const byte CONSTRUCTED = 0x20; + public const byte CONSTRUCTED = 0x20; /// /// Constant value. /// - public const byte APPLICATION = 0x40; + public const byte APPLICATION = 0x40; /// /// Constant value. /// - public const byte CONTEXT_SPECIFIC = 0x80; + public const byte CONTEXT_SPECIFIC = 0x80; /// /// Constant value. /// - public const byte PRIVATE = 0xc0; + public const byte PRIVATE = 0xc0; /// /// Constructor. diff --git a/Runtime/Security/Asn1Processor/Asn1Util.cs b/Runtime/Security/Asn1Processor/Asn1Util.cs index 58a57c6..721799e 100644 --- a/Runtime/Security/Asn1Processor/Asn1Util.cs +++ b/Runtime/Security/Asn1Processor/Asn1Util.cs @@ -31,750 +31,750 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// Utility functions. - /// - internal class Asn1Util - { - - /// - /// Check if the string is ASN.1 encoded hex string. - /// - /// The string. - /// true:Yes, false:No. - public static bool IsAsn1EncodedHexStr(string dataStr) - { - bool retval = false; - try - { - byte[] data = HexStrToBytes(dataStr); - if (data.Length > 0) - { - Asn1Node node = new Asn1Node(); - retval = node.LoadData(data); - } - } - catch - { - retval = false; - } - return retval; - } - - /// - /// Format a string to have certain line length and character group length. - /// Sample result FormatString(xstr,32,2): - /// 07 AE 0B E7 84 5A D4 6C 6A BD DF 8F 89 88 9E F1 - /// - /// source string. - /// line length. - /// group length. - /// - public static string FormatString(string inStr, int lineLen, int groupLen) - { - char[] tmpCh = new char[inStr.Length*2]; - int i, c = 0, linec = 0; - int gc = 0; - for (i=0; i= groupLen && groupLen > 0) - { - tmpCh[c++] = ' '; - gc = 0; - } - if (linec >= lineLen) - { - tmpCh[c++] = '\r'; - tmpCh[c++] = '\n'; - linec = 0; - } - } - string retval = new string(tmpCh); - retval = retval.TrimEnd('\0'); - retval = retval.TrimEnd('\n'); - retval = retval.TrimEnd('\r'); - return retval; - } - - /// - /// Generate a string by duplicating xch. - /// - /// duplicate times. - /// the duplicated character. - /// - public static string GenStr(int len, char xch) - { - char[] ch = new char[len]; - for (int i = 0; i - /// Convert byte array to a integer. - /// - /// - /// - public static long BytesToLong(byte[] bytes) - { - long tempInt = 0; - for(int i=0; i - /// Convert a ASCII byte array to string, also filter out the null characters. - /// - /// - /// - public static string BytesToString(byte[] bytes) - { - string retval = ""; - if (bytes == null || bytes.Length < 1) return retval; - char[] cretval = new char[bytes.Length]; - for (int i=0, j=0; i - /// Convert ASCII string to byte array. - /// - /// - /// - public static byte[] StringToBytes(string msg) - { - byte[] retval = new byte[msg.Length]; - for (int i=0; i - /// Compare source and target byte array. - /// - /// - /// - /// - public static bool IsEqual(byte[] source, byte[] target) - { - if (source == null) return false; - if (target == null) return false; - if (source.Length != target.Length) return false; - for (int i=0; i - /// Constant hex digits array. - /// - static char[] hexDigits = { '0', '1', '2', '3', '4', '5', '6', '7', - '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; - - /// - /// Convert a byte array to hex string. - /// - /// source array. - /// hex string. - public static string ToHexString(byte[] bytes) - { - if (bytes == null) return ""; - char[] chars = new char[bytes.Length * 2]; - int b, i; - for (i = 0; i < bytes.Length; i++) - { - b = bytes[i]; - chars[i * 2] = hexDigits[b >> 4]; - chars[i * 2 + 1] = hexDigits[b & 0xF]; - } - return new string(chars); - } - - /// - /// Check if the character is a valid hex digits. - /// - /// source character. - /// true:Valid, false:Invalid. - public static bool IsValidHexDigits(char ch) - { - bool retval = false; - for (int i=0; i - /// Get hex digits value. - /// - /// source character. - /// hex digits value. - public static byte GetHexDigitsVal(char ch) - { - byte retval = 0; - for (int i=0; i - /// Convert hex string to byte array. - /// - /// Source hex string. - /// return byte array. - public static byte[] HexStrToBytes(string hexStr) - { - hexStr = hexStr.Replace(" ", ""); - hexStr = hexStr.Replace("\r", ""); - hexStr = hexStr.Replace("\n", ""); - hexStr = hexStr.ToUpper(); - if ((hexStr.Length%2) != 0) throw new Exception("Invalid Hex string: odd length."); - int i; - for (i=0; i - /// Check if the source string is a valid hex string. - /// - /// source string. - /// true:Valid, false:Invalid. - public static bool IsHexStr(string hexStr) - { - byte[] bytes = null; - try - { - bytes = HexStrToBytes(hexStr); - } - catch - { - return false; - } - if (bytes == null || bytes.Length < 0) - { - return false; - } - else - { - return true; - } - } - - private const string PemStartStr = "-----BEGIN"; - private const string PemEndStr = "-----END"; - /// - /// Check if the source string is PEM formated string. - /// - /// source string. - /// true:Valid, false:Invalid. - public static bool IsPemFormated(string pemStr) - { - byte[] data = null; - try - { - data = PemToBytes(pemStr); - } - catch - { - return false; - } - return (data.Length > 0); - } - - /// - /// Check if a file is PEM formated. - /// - /// source file name. - /// true:Yes, false:No. - public static bool IsPemFormatedFile(string fileName) - { - bool retval = false; - try - { - FileStream fs = new FileStream(fileName, System.IO.FileMode.Open); - byte[] data = new byte[fs.Length]; - fs.Read(data, 0, data.Length); - fs.Close(); - string dataStr = Asn1Util.BytesToString(data); - retval = IsPemFormated(dataStr); - } - catch - { - retval = false; - } - return retval; - } - - /// - /// Convert PEM formated string into and set the Stream position to 0. - /// - /// source string. - /// output stream. - public static Stream PemToStream(string pemStr) - { - byte[] bytes = PemToBytes(pemStr); - MemoryStream retval = new MemoryStream(bytes); - retval.Position = 0; - return retval; - } - - /// - /// Convert PEM formated string into byte array. - /// - /// source string. - /// output byte array. - public static byte[] PemToBytes(string pemStr) - { - byte[] retval = null; - string[] lines = pemStr.Split('\n'); - string base64Str = ""; - bool started = false, ended = false; - string cline = ""; - for (int i = 0; i PemStartStr.Length) - { - if (!started && cline.Substring(0, PemStartStr.Length) == PemStartStr) - { - started = true; - continue; - } - } - if (cline.Length > PemEndStr.Length) - { - if (cline.Substring(0, PemEndStr.Length) == PemEndStr) - { - ended = true; - break; - } - } - if (started) - { - base64Str += lines[i]; - } - } - if (!(started && ended)) - { - throw new Exception("'BEGIN'/'END' line is missing."); - } - base64Str = base64Str.Replace("\r", ""); - base64Str = base64Str.Replace("\n", ""); - base64Str = base64Str.Replace("\n", " "); - retval = Convert.FromBase64String(base64Str); - return retval; - } - - /// - /// Convert byte array to PEM formated string. - /// - /// - /// - public static string BytesToPem(byte[] data) - { - return BytesToPem(data, ""); - } - - /// - /// Retrieve PEM file heading. - /// - /// source file name. - /// heading string. - public static string GetPemFileHeader(string fileName) - { - try - { - FileStream fs = new FileStream(fileName, FileMode.Open); - byte[] data = new byte[fs.Length]; - fs.Read(data, 0, data.Length); - fs.Close(); - string dataStr = Asn1Util.BytesToString(data); - return GetPemHeader(dataStr); - } - catch - { - return ""; - } - } - - /// - /// Retrieve PEM heading from a PEM formated string. - /// - /// source string. - /// heading string. - public static string GetPemHeader(string pemStr) - { - string[] lines = pemStr.Split('\n'); - bool started = false; - string cline = ""; - for (int i = 0; i PemStartStr.Length) - { - if (!started && cline.Substring(0, PemStartStr.Length) == PemStartStr) - { - started = true; - string retstr = lines[i].Substring(PemStartStr.Length, - lines[i].Length - - PemStartStr.Length).Replace("-----",""); - return retstr.Replace("\r", ""); - } - } - else - { - continue; - } - } - return ""; - } - - /// - /// Convert byte array to PEM formated string and set the heading as pemHeader. - /// - /// source array. - /// PEM heading. - /// PEM formated string. - public static string BytesToPem(byte[] data, string pemHeader) - { - if (pemHeader == null || pemHeader.Length<1) - { - pemHeader = "ASN.1 Editor Generated PEM File"; - } - string retval = ""; - if (pemHeader.Length > 0 && pemHeader[0] != ' ') - { - pemHeader = " " + pemHeader; - } - retval = Convert.ToBase64String(data); - retval = FormatString(retval, 64, 0); - retval = "-----BEGIN"+ pemHeader +"-----\r\n" + - retval + - "\r\n-----END"+ pemHeader +"-----\r\n"; - return retval; - } - - /// - /// Calculate how many bits is enough to hold ivalue. - /// - /// source value. - /// bits number. - public static int BitPrecision(ulong ivalue) - { - if (ivalue == 0) return 0; - int l = 0, h = 8 * 4; // 4: sizeof(ulong) - while (h-l > 1) - { - int t = (int) (l+h)/2; - if ((ivalue >> t) != 0) - l = t; - else - h = t; - } - return h; - } - - /// - /// Calculate how many bytes is enough to hold the value. - /// - /// input value. - /// bytes number. - public static int BytePrecision(ulong value) - { - int i; - for (i = 4; i > 0; --i) // 4: sizeof(ulong) - if ((value >> (i-1)*8)!=0) - break; - return i; - } - - /// - /// ASN.1 DER length encoder. - /// - /// result output stream. - /// source length. - /// result bytes. - public static int DERLengthEncode(Stream xdata, ulong length) - { - int i=0; - if (length <= 0x7f) - { - xdata.WriteByte((byte)length); - i++; - } - else - { - xdata.WriteByte((byte)(BytePrecision(length) | 0x80)); - i++; - for (int j=BytePrecision((ulong)length); j>0; --j) - { - xdata.WriteByte((byte)(length >> (j-1)*8)); - i++; - } - } - return i; - } - - /// - /// ASN.1 DER length decoder. - /// - /// Source stream. - /// Output parameter. - /// Output length. - public static long DerLengthDecode(Stream bt, ref bool isIndefiniteLength) - { - isIndefiniteLength = false; - long length = 0; - byte b; - b = (byte) bt.ReadByte(); - if ((b & 0x80)==0) - { - length = b; - } - else - { - long lengthBytes = b & 0x7f; - if (lengthBytes == 0) - { - isIndefiniteLength = true; - return -2; // Indefinite length. - } - - length = 0; - while (lengthBytes-- > 0) - { - if ((length >> (8 * (4 - 1))) > 0) // 4: sizeof(long) - { - return -1; // Length overflow. - } - b = (byte) bt.ReadByte(); - length = (length << 8) | b; - } + /// + /// Utility functions. + /// + internal class Asn1Util + { + + /// + /// Check if the string is ASN.1 encoded hex string. + /// + /// The string. + /// true:Yes, false:No. + public static bool IsAsn1EncodedHexStr(string dataStr) + { + bool retval = false; + try + { + byte[] data = HexStrToBytes(dataStr); + if (data.Length > 0) + { + Asn1Node node = new Asn1Node(); + retval = node.LoadData(data); + } + } + catch + { + retval = false; + } + return retval; + } + + /// + /// Format a string to have certain line length and character group length. + /// Sample result FormatString(xstr,32,2): + /// 07 AE 0B E7 84 5A D4 6C 6A BD DF 8F 89 88 9E F1 + /// + /// source string. + /// line length. + /// group length. + /// + public static string FormatString(string inStr, int lineLen, int groupLen) + { + char[] tmpCh = new char[inStr.Length * 2]; + int i, c = 0, linec = 0; + int gc = 0; + for (i = 0; i < inStr.Length; i++) + { + tmpCh[c++] = inStr[i]; + gc++; + linec++; + if (gc >= groupLen && groupLen > 0) + { + tmpCh[c++] = ' '; + gc = 0; + } + if (linec >= lineLen) + { + tmpCh[c++] = '\r'; + tmpCh[c++] = '\n'; + linec = 0; + } + } + string retval = new string(tmpCh); + retval = retval.TrimEnd('\0'); + retval = retval.TrimEnd('\n'); + retval = retval.TrimEnd('\r'); + return retval; + } + + /// + /// Generate a string by duplicating xch. + /// + /// duplicate times. + /// the duplicated character. + /// + public static string GenStr(int len, char xch) + { + char[] ch = new char[len]; + for (int i = 0; i < len; i++) + { + ch[i] = xch; + } + return new string(ch); + } + + /// + /// Convert byte array to a integer. + /// + /// + /// + public static long BytesToLong(byte[] bytes) + { + long tempInt = 0; + for (int i = 0; i < bytes.Length; i++) + { + tempInt = tempInt << 8 | bytes[i]; + } + return tempInt; + } + + /// + /// Convert a ASCII byte array to string, also filter out the null characters. + /// + /// + /// + public static string BytesToString(byte[] bytes) + { + string retval = ""; + if (bytes == null || bytes.Length < 1) return retval; + char[] cretval = new char[bytes.Length]; + for (int i = 0, j = 0; i < bytes.Length; i++) + { + if (bytes[i] != '\0') + { + cretval[j++] = (char)bytes[i]; + } + } + retval = new string(cretval); + retval = retval.TrimEnd('\0'); + return retval; + } + + /// + /// Convert ASCII string to byte array. + /// + /// + /// + public static byte[] StringToBytes(string msg) + { + byte[] retval = new byte[msg.Length]; + for (int i = 0; i < msg.Length; i++) + { + retval[i] = (byte)msg[i]; + } + return retval; + } + + /// + /// Compare source and target byte array. + /// + /// + /// + /// + public static bool IsEqual(byte[] source, byte[] target) + { + if (source == null) return false; + if (target == null) return false; + if (source.Length != target.Length) return false; + for (int i = 0; i < source.Length; i++) + { + if (source[i] != target[i]) return false; + } + return true; + } + + /// + /// Constant hex digits array. + /// + static char[] hexDigits = { '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; + + /// + /// Convert a byte array to hex string. + /// + /// source array. + /// hex string. + public static string ToHexString(byte[] bytes) + { + if (bytes == null) return ""; + char[] chars = new char[bytes.Length * 2]; + int b, i; + for (i = 0; i < bytes.Length; i++) + { + b = bytes[i]; + chars[i * 2] = hexDigits[b >> 4]; + chars[i * 2 + 1] = hexDigits[b & 0xF]; + } + return new string(chars); + } + + /// + /// Check if the character is a valid hex digits. + /// + /// source character. + /// true:Valid, false:Invalid. + public static bool IsValidHexDigits(char ch) + { + bool retval = false; + for (int i = 0; i < hexDigits.Length; i++) + { + if (hexDigits[i] == ch) + { + retval = true; + break; + } + } + return retval; + } + + /// + /// Get hex digits value. + /// + /// source character. + /// hex digits value. + public static byte GetHexDigitsVal(char ch) + { + byte retval = 0; + for (int i = 0; i < hexDigits.Length; i++) + { + if (hexDigits[i] == ch) + { + retval = (byte)i; + break; + } + } + return retval; + } + + /// + /// Convert hex string to byte array. + /// + /// Source hex string. + /// return byte array. + public static byte[] HexStrToBytes(string hexStr) + { + hexStr = hexStr.Replace(" ", ""); + hexStr = hexStr.Replace("\r", ""); + hexStr = hexStr.Replace("\n", ""); + hexStr = hexStr.ToUpper(); + if ((hexStr.Length % 2) != 0) throw new Exception("Invalid Hex string: odd length."); + int i; + for (i = 0; i < hexStr.Length; i++) + { + if (!IsValidHexDigits(hexStr[i])) + { + throw new Exception("Invalid Hex string: included invalid character [" + + hexStr[i] + "]"); + } + } + int bc = hexStr.Length / 2; + byte[] retval = new byte[bc]; + int b1, b2, b; + for (i = 0; i < bc; i++) + { + b1 = GetHexDigitsVal(hexStr[i * 2]); + b2 = GetHexDigitsVal(hexStr[i * 2 + 1]); + b = ((b1 << 4) | b2); + retval[i] = (byte)b; + } + return retval; + } + + /// + /// Check if the source string is a valid hex string. + /// + /// source string. + /// true:Valid, false:Invalid. + public static bool IsHexStr(string hexStr) + { + byte[] bytes = null; + try + { + bytes = HexStrToBytes(hexStr); + } + catch + { + return false; + } + if (bytes == null || bytes.Length < 0) + { + return false; + } + else + { + return true; + } + } + + private const string PemStartStr = "-----BEGIN"; + private const string PemEndStr = "-----END"; + /// + /// Check if the source string is PEM formated string. + /// + /// source string. + /// true:Valid, false:Invalid. + public static bool IsPemFormated(string pemStr) + { + byte[] data = null; + try + { + data = PemToBytes(pemStr); + } + catch + { + return false; + } + return (data.Length > 0); + } + + /// + /// Check if a file is PEM formated. + /// + /// source file name. + /// true:Yes, false:No. + public static bool IsPemFormatedFile(string fileName) + { + bool retval = false; + try + { + FileStream fs = new FileStream(fileName, System.IO.FileMode.Open); + byte[] data = new byte[fs.Length]; + fs.Read(data, 0, data.Length); + fs.Close(); + string dataStr = Asn1Util.BytesToString(data); + retval = IsPemFormated(dataStr); + } + catch + { + retval = false; + } + return retval; + } + + /// + /// Convert PEM formated string into and set the Stream position to 0. + /// + /// source string. + /// output stream. + public static Stream PemToStream(string pemStr) + { + byte[] bytes = PemToBytes(pemStr); + MemoryStream retval = new MemoryStream(bytes); + retval.Position = 0; + return retval; + } + + /// + /// Convert PEM formated string into byte array. + /// + /// source string. + /// output byte array. + public static byte[] PemToBytes(string pemStr) + { + byte[] retval = null; + string[] lines = pemStr.Split('\n'); + string base64Str = ""; + bool started = false, ended = false; + string cline = ""; + for (int i = 0; i < lines.Length; i++) + { + cline = lines[i].ToUpper(); + if (cline == "") continue; + if (cline.Length > PemStartStr.Length) + { + if (!started && cline.Substring(0, PemStartStr.Length) == PemStartStr) + { + started = true; + continue; + } + } + if (cline.Length > PemEndStr.Length) + { + if (cline.Substring(0, PemEndStr.Length) == PemEndStr) + { + ended = true; + break; + } + } + if (started) + { + base64Str += lines[i]; + } + } + if (!(started && ended)) + { + throw new Exception("'BEGIN'/'END' line is missing."); + } + base64Str = base64Str.Replace("\r", ""); + base64Str = base64Str.Replace("\n", ""); + base64Str = base64Str.Replace("\n", " "); + retval = Convert.FromBase64String(base64Str); + return retval; + } + + /// + /// Convert byte array to PEM formated string. + /// + /// + /// + public static string BytesToPem(byte[] data) + { + return BytesToPem(data, ""); + } + + /// + /// Retrieve PEM file heading. + /// + /// source file name. + /// heading string. + public static string GetPemFileHeader(string fileName) + { + try + { + FileStream fs = new FileStream(fileName, FileMode.Open); + byte[] data = new byte[fs.Length]; + fs.Read(data, 0, data.Length); + fs.Close(); + string dataStr = Asn1Util.BytesToString(data); + return GetPemHeader(dataStr); + } + catch + { + return ""; + } + } + + /// + /// Retrieve PEM heading from a PEM formated string. + /// + /// source string. + /// heading string. + public static string GetPemHeader(string pemStr) + { + string[] lines = pemStr.Split('\n'); + bool started = false; + string cline = ""; + for (int i = 0; i < lines.Length; i++) + { + cline = lines[i].ToUpper().Replace("\r", ""); + if (cline == "") continue; + if (cline.Length > PemStartStr.Length) + { + if (!started && cline.Substring(0, PemStartStr.Length) == PemStartStr) + { + started = true; + string retstr = lines[i].Substring(PemStartStr.Length, + lines[i].Length - + PemStartStr.Length).Replace("-----", ""); + return retstr.Replace("\r", ""); + } + } + else + { + continue; + } + } + return ""; + } + + /// + /// Convert byte array to PEM formated string and set the heading as pemHeader. + /// + /// source array. + /// PEM heading. + /// PEM formated string. + public static string BytesToPem(byte[] data, string pemHeader) + { + if (pemHeader == null || pemHeader.Length < 1) + { + pemHeader = "ASN.1 Editor Generated PEM File"; + } + string retval = ""; + if (pemHeader.Length > 0 && pemHeader[0] != ' ') + { + pemHeader = " " + pemHeader; + } + retval = Convert.ToBase64String(data); + retval = FormatString(retval, 64, 0); + retval = "-----BEGIN" + pemHeader + "-----\r\n" + + retval + + "\r\n-----END" + pemHeader + "-----\r\n"; + return retval; + } + + /// + /// Calculate how many bits is enough to hold ivalue. + /// + /// source value. + /// bits number. + public static int BitPrecision(ulong ivalue) + { + if (ivalue == 0) return 0; + int l = 0, h = 8 * 4; // 4: sizeof(ulong) + while (h - l > 1) + { + int t = (int)(l + h) / 2; + if ((ivalue >> t) != 0) + l = t; + else + h = t; + } + return h; + } + + /// + /// Calculate how many bytes is enough to hold the value. + /// + /// input value. + /// bytes number. + public static int BytePrecision(ulong value) + { + int i; + for (i = 4; i > 0; --i) // 4: sizeof(ulong) + if ((value >> (i - 1) * 8) != 0) + break; + return i; + } + + /// + /// ASN.1 DER length encoder. + /// + /// result output stream. + /// source length. + /// result bytes. + public static int DERLengthEncode(Stream xdata, ulong length) + { + int i = 0; + if (length <= 0x7f) + { + xdata.WriteByte((byte)length); + i++; + } + else + { + xdata.WriteByte((byte)(BytePrecision(length) | 0x80)); + i++; + for (int j = BytePrecision((ulong)length); j > 0; --j) + { + xdata.WriteByte((byte)(length >> (j - 1) * 8)); + i++; + } + } + return i; + } + + /// + /// ASN.1 DER length decoder. + /// + /// Source stream. + /// Output parameter. + /// Output length. + public static long DerLengthDecode(Stream bt, ref bool isIndefiniteLength) + { + isIndefiniteLength = false; + long length = 0; + byte b; + b = (byte)bt.ReadByte(); + if ((b & 0x80) == 0) + { + length = b; + } + else + { + long lengthBytes = b & 0x7f; + if (lengthBytes == 0) + { + isIndefiniteLength = true; + return -2; // Indefinite length. + } + + length = 0; + while (lengthBytes-- > 0) + { + if ((length >> (8 * (4 - 1))) > 0) // 4: sizeof(long) + { + return -1; // Length overflow. + } + b = (byte)bt.ReadByte(); + length = (length << 8) | b; + } if (length <= 0x7f) { return -1; // Indicated false node } - } - return length; - } - - /// - /// Decode tag value to return tag name. - /// - /// input tag. - /// tag name. - static public string GetTagName(byte tag) - { - string retval = ""; - if ((tag & Asn1TagClasses.CLASS_MASK) != 0) - { - switch (tag & Asn1TagClasses.CLASS_MASK) - { - case Asn1TagClasses.CONTEXT_SPECIFIC: - retval += "CONTEXT SPECIFIC (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() +")"; - break; - case Asn1TagClasses.APPLICATION: - retval += "APPLICATION (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() +")"; - break; - case Asn1TagClasses.PRIVATE: - retval += "PRIVATE (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() +")"; - break; - case Asn1TagClasses.CONSTRUCTED: - retval += "CONSTRUCTED (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() +")"; - break; - case Asn1TagClasses.UNIVERSAL: - retval += "UNIVERSAL (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() +")"; - break; - } - } - else - { - switch (tag & Asn1Tag.TAG_MASK) - { - case Asn1Tag.BOOLEAN: - retval += "BOOLEAN"; - break; - case Asn1Tag.INTEGER: - retval += "INTEGER"; - break; - case Asn1Tag.BIT_STRING: - retval += "BIT STRING"; - break; - case Asn1Tag.OCTET_STRING: - retval += "OCTET STRING"; - break; - case Asn1Tag.TAG_NULL: - retval += "NULL"; - break; - case Asn1Tag.OBJECT_IDENTIFIER: - retval += "OBJECT IDENTIFIER"; - break; - case Asn1Tag.OBJECT_DESCRIPTOR: - retval += "OBJECT DESCRIPTOR"; - break; - case Asn1Tag.RELATIVE_OID: - retval += "RELATIVE-OID"; - break; - case Asn1Tag.EXTERNAL: - retval += "EXTERNAL"; - break; - case Asn1Tag.REAL: - retval += "REAL"; - break; - case Asn1Tag.ENUMERATED: - retval += "ENUMERATED"; - break; - case Asn1Tag.UTF8_STRING: - retval += "UTF8 STRING"; - break; - case (Asn1Tag.SEQUENCE): - retval += "SEQUENCE"; - break; - case (Asn1Tag.SET): - retval += "SET"; - break; - case Asn1Tag.NUMERIC_STRING: - retval += "NUMERIC STRING"; - break; - case Asn1Tag.PRINTABLE_STRING: - retval += "PRINTABLE STRING"; - break; - case Asn1Tag.T61_STRING: - retval += "T61 STRING"; - break; - case Asn1Tag.VIDEOTEXT_STRING: - retval += "VIDEOTEXT STRING"; - break; - case Asn1Tag.IA5_STRING: - retval += "IA5 STRING"; - break; - case Asn1Tag.UTC_TIME: - retval += "UTC TIME"; - break; - case Asn1Tag.GENERALIZED_TIME: - retval += "GENERALIZED TIME"; - break; - case Asn1Tag.GRAPHIC_STRING: - retval += "GRAPHIC STRING"; - break; - case Asn1Tag.VISIBLE_STRING: - retval += "VISIBLE STRING"; - break; - case Asn1Tag.GENERAL_STRING: - retval += "GENERAL STRING"; - break; - case Asn1Tag.UNIVERSAL_STRING: - retval += "UNIVERSAL STRING"; - break; - case Asn1Tag.BMPSTRING: - retval += "BMP STRING"; - break; - default: - retval += "UNKNOWN TAG"; - break; - }; - } - return retval; - } + } + return length; + } + + /// + /// Decode tag value to return tag name. + /// + /// input tag. + /// tag name. + static public string GetTagName(byte tag) + { + string retval = ""; + if ((tag & Asn1TagClasses.CLASS_MASK) != 0) + { + switch (tag & Asn1TagClasses.CLASS_MASK) + { + case Asn1TagClasses.CONTEXT_SPECIFIC: + retval += "CONTEXT SPECIFIC (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() + ")"; + break; + case Asn1TagClasses.APPLICATION: + retval += "APPLICATION (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() + ")"; + break; + case Asn1TagClasses.PRIVATE: + retval += "PRIVATE (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() + ")"; + break; + case Asn1TagClasses.CONSTRUCTED: + retval += "CONSTRUCTED (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() + ")"; + break; + case Asn1TagClasses.UNIVERSAL: + retval += "UNIVERSAL (" + ((int)(tag & Asn1Tag.TAG_MASK)).ToString() + ")"; + break; + } + } + else + { + switch (tag & Asn1Tag.TAG_MASK) + { + case Asn1Tag.BOOLEAN: + retval += "BOOLEAN"; + break; + case Asn1Tag.INTEGER: + retval += "INTEGER"; + break; + case Asn1Tag.BIT_STRING: + retval += "BIT STRING"; + break; + case Asn1Tag.OCTET_STRING: + retval += "OCTET STRING"; + break; + case Asn1Tag.TAG_NULL: + retval += "NULL"; + break; + case Asn1Tag.OBJECT_IDENTIFIER: + retval += "OBJECT IDENTIFIER"; + break; + case Asn1Tag.OBJECT_DESCRIPTOR: + retval += "OBJECT DESCRIPTOR"; + break; + case Asn1Tag.RELATIVE_OID: + retval += "RELATIVE-OID"; + break; + case Asn1Tag.EXTERNAL: + retval += "EXTERNAL"; + break; + case Asn1Tag.REAL: + retval += "REAL"; + break; + case Asn1Tag.ENUMERATED: + retval += "ENUMERATED"; + break; + case Asn1Tag.UTF8_STRING: + retval += "UTF8 STRING"; + break; + case (Asn1Tag.SEQUENCE): + retval += "SEQUENCE"; + break; + case (Asn1Tag.SET): + retval += "SET"; + break; + case Asn1Tag.NUMERIC_STRING: + retval += "NUMERIC STRING"; + break; + case Asn1Tag.PRINTABLE_STRING: + retval += "PRINTABLE STRING"; + break; + case Asn1Tag.T61_STRING: + retval += "T61 STRING"; + break; + case Asn1Tag.VIDEOTEXT_STRING: + retval += "VIDEOTEXT STRING"; + break; + case Asn1Tag.IA5_STRING: + retval += "IA5 STRING"; + break; + case Asn1Tag.UTC_TIME: + retval += "UTC TIME"; + break; + case Asn1Tag.GENERALIZED_TIME: + retval += "GENERALIZED TIME"; + break; + case Asn1Tag.GRAPHIC_STRING: + retval += "GRAPHIC STRING"; + break; + case Asn1Tag.VISIBLE_STRING: + retval += "VISIBLE STRING"; + break; + case Asn1Tag.GENERAL_STRING: + retval += "GENERAL STRING"; + break; + case Asn1Tag.UNIVERSAL_STRING: + retval += "UNIVERSAL STRING"; + break; + case Asn1Tag.BMPSTRING: + retval += "BMP STRING"; + break; + default: + retval += "UNKNOWN TAG"; + break; + }; + } + return retval; + } #if UNITYIAP_DISABLED - /// - /// Read registry information from local machine entrys. - /// - /// - /// - /// - static public object ReadRegInfo(string path, string name) - { - object retval = null; - Microsoft.Win32.RegistryKey regKey; - regKey = Registry.LocalMachine.OpenSubKey(path, false); - if (regKey!=null) - { - retval = regKey.GetValue(name); - } - return retval; - } - - /// - /// Write information into local machine registry entry. - /// - /// - /// - /// - static public void WriteRegInfo(string path, string name, object data) - { - Microsoft.Win32.RegistryKey regKey; - regKey = Registry.LocalMachine.OpenSubKey(path, true); - if (regKey == null) - { - regKey = Registry.LocalMachine.CreateSubKey(path); - } - if (regKey != null) - { - regKey.SetValue(name, data); - } - } + /// + /// Read registry information from local machine entrys. + /// + /// + /// + /// + static public object ReadRegInfo(string path, string name) + { + object retval = null; + Microsoft.Win32.RegistryKey regKey; + regKey = Registry.LocalMachine.OpenSubKey(path, false); + if (regKey!=null) + { + retval = regKey.GetValue(name); + } + return retval; + } + + /// + /// Write information into local machine registry entry. + /// + /// + /// + /// + static public void WriteRegInfo(string path, string name, object data) + { + Microsoft.Win32.RegistryKey regKey; + regKey = Registry.LocalMachine.OpenSubKey(path, true); + if (regKey == null) + { + regKey = Registry.LocalMachine.CreateSubKey(path); + } + if (regKey != null) + { + regKey.SetValue(name, data); + } + } #endif - /// - /// Constructor. - /// - private Asn1Util() - { - //Private constructor. - } + /// + /// Constructor. + /// + private Asn1Util() + { + //Private constructor. + } - } + } } diff --git a/Runtime/Security/Asn1Processor/IAsn1Node.cs b/Runtime/Security/Asn1Processor/IAsn1Node.cs index b80b400..a3395bf 100644 --- a/Runtime/Security/Asn1Processor/IAsn1Node.cs +++ b/Runtime/Security/Asn1Processor/IAsn1Node.cs @@ -28,193 +28,193 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// IAsn1Node interface. - /// - internal interface IAsn1Node - { - /// - /// Load data from Stream. - /// - /// - /// true:Succeed; false:failed. - bool LoadData(Stream xdata); - - /// - /// Save node data into Stream. - /// - /// Stream. - /// true:Succeed; false:failed. - bool SaveData(Stream xdata); - - /// - /// Get parent node. - /// - Asn1Node ParentNode { get; } - - /// - /// Add child node at the end of children list. - /// - /// Asn1Node - void AddChild(Asn1Node xdata); - - /// - /// Insert a node in the children list before the pointed index. - /// - /// Asn1Node - /// 0 based index. - int InsertChild(Asn1Node xdata, int index); - - /// - /// Insert a node in the children list before the pointed node. - /// - /// Asn1Node that will be instered in the children list. - /// Index node. - /// New node index. - int InsertChild(Asn1Node xdata, Asn1Node indexNode); - - /// - /// Insert a node in the children list after the pointed index. - /// - /// Asn1Node - /// 0 based index. - /// New node index. - int InsertChildAfter(Asn1Node xdata, int index); - - /// - /// Insert a node in the children list after the pointed node. - /// - /// Asn1Node that will be instered in the children list. - /// Index node. - /// New node index. - int InsertChildAfter(Asn1Node xdata, Asn1Node indexNode); - - /// - /// Remove a child from children node list by index. - /// - /// 0 based index. - /// The Asn1Node just removed from the list. - Asn1Node RemoveChild(int index); - - /// - /// Remove the child from children node list. - /// - /// The node needs to be removed. - /// - Asn1Node RemoveChild(Asn1Node node); - - /// - /// Get child node count. - /// - long ChildNodeCount { get; } - - /// - /// Retrieve child node by index. - /// - /// 0 based index. - /// 0 based index. - Asn1Node GetChildNode(int index); - - /// - /// Get descendant node by node path. - /// - /// relative node path that refer to current node. - /// - Asn1Node GetDescendantNodeByPath(string nodePath); - - /// - /// Get/Set tag value. - /// - byte Tag{ get; set; } - - byte MaskedTag { get; } - - /// - /// Get tag name. - /// - string TagName{ get; } - - /// - /// Get data length. Not included the unused bits byte for BITSTRING. - /// - long DataLength{ get; } - - /// - /// Get the length field bytes. - /// - long LengthFieldBytes{ get; } - - /// - /// Get data offset. - /// - long DataOffset{ get; } - - /// - /// Get unused bits for BITSTRING. - /// - byte UnusedBits{ get; } - - /// - /// Get/Set node data by byte[], the data length field content and all the - /// node in the parent chain will be adjusted. - /// - byte[] Data { get; set; } - - /// - /// Get/Set parseEncapsulatedData. This property will be inherited by the - /// child nodes when loading data. - /// - bool ParseEncapsulatedData { get; set; } - - /// - /// Get the deepness of the node. - /// - long Deepness { get; } - - /// - /// Get the path string of the node. - /// - string Path{ get; } - - /// - /// Get the node and all the descendents text description. - /// - /// starting node. - /// line length. - /// - string GetText(Asn1Node startNode, int lineLen); - - /// - /// Retrieve the node description. - /// - /// true:Return hex string only; - /// false:Convert to more readable string depending on the node tag. - /// string - string GetDataStr(bool pureHexMode); - - /// - /// Get node label string. - /// - /// - /// - /// SHOW_OFFSET - /// SHOW_DATA - /// USE_HEX_OFFSET - /// SHOW_TAG_NUMBER - /// SHOW_PATH - /// - /// string - string GetLabel(uint mask); - - /// - /// Clone a new Asn1Node by current node. - /// - /// new node. - Asn1Node Clone(); - - /// - /// Clear data and children list. - /// - void ClearAll(); - } + /// + /// IAsn1Node interface. + /// + internal interface IAsn1Node + { + /// + /// Load data from Stream. + /// + /// + /// true:Succeed; false:failed. + bool LoadData(Stream xdata); + + /// + /// Save node data into Stream. + /// + /// Stream. + /// true:Succeed; false:failed. + bool SaveData(Stream xdata); + + /// + /// Get parent node. + /// + Asn1Node ParentNode { get; } + + /// + /// Add child node at the end of children list. + /// + /// Asn1Node + void AddChild(Asn1Node xdata); + + /// + /// Insert a node in the children list before the pointed index. + /// + /// Asn1Node + /// 0 based index. + int InsertChild(Asn1Node xdata, int index); + + /// + /// Insert a node in the children list before the pointed node. + /// + /// Asn1Node that will be instered in the children list. + /// Index node. + /// New node index. + int InsertChild(Asn1Node xdata, Asn1Node indexNode); + + /// + /// Insert a node in the children list after the pointed index. + /// + /// Asn1Node + /// 0 based index. + /// New node index. + int InsertChildAfter(Asn1Node xdata, int index); + + /// + /// Insert a node in the children list after the pointed node. + /// + /// Asn1Node that will be instered in the children list. + /// Index node. + /// New node index. + int InsertChildAfter(Asn1Node xdata, Asn1Node indexNode); + + /// + /// Remove a child from children node list by index. + /// + /// 0 based index. + /// The Asn1Node just removed from the list. + Asn1Node RemoveChild(int index); + + /// + /// Remove the child from children node list. + /// + /// The node needs to be removed. + /// + Asn1Node RemoveChild(Asn1Node node); + + /// + /// Get child node count. + /// + long ChildNodeCount { get; } + + /// + /// Retrieve child node by index. + /// + /// 0 based index. + /// 0 based index. + Asn1Node GetChildNode(int index); + + /// + /// Get descendant node by node path. + /// + /// relative node path that refer to current node. + /// + Asn1Node GetDescendantNodeByPath(string nodePath); + + /// + /// Get/Set tag value. + /// + byte Tag { get; set; } + + byte MaskedTag { get; } + + /// + /// Get tag name. + /// + string TagName { get; } + + /// + /// Get data length. Not included the unused bits byte for BITSTRING. + /// + long DataLength { get; } + + /// + /// Get the length field bytes. + /// + long LengthFieldBytes { get; } + + /// + /// Get data offset. + /// + long DataOffset { get; } + + /// + /// Get unused bits for BITSTRING. + /// + byte UnusedBits { get; } + + /// + /// Get/Set node data by byte[], the data length field content and all the + /// node in the parent chain will be adjusted. + /// + byte[] Data { get; set; } + + /// + /// Get/Set parseEncapsulatedData. This property will be inherited by the + /// child nodes when loading data. + /// + bool ParseEncapsulatedData { get; set; } + + /// + /// Get the deepness of the node. + /// + long Deepness { get; } + + /// + /// Get the path string of the node. + /// + string Path { get; } + + /// + /// Get the node and all the descendents text description. + /// + /// starting node. + /// line length. + /// + string GetText(Asn1Node startNode, int lineLen); + + /// + /// Retrieve the node description. + /// + /// true:Return hex string only; + /// false:Convert to more readable string depending on the node tag. + /// string + string GetDataStr(bool pureHexMode); + + /// + /// Get node label string. + /// + /// + /// + /// SHOW_OFFSET + /// SHOW_DATA + /// USE_HEX_OFFSET + /// SHOW_TAG_NUMBER + /// SHOW_PATH + /// + /// string + string GetLabel(uint mask); + + /// + /// Clone a new Asn1Node by current node. + /// + /// new node. + Asn1Node Clone(); + + /// + /// Clear data and children list. + /// + void ClearAll(); + } } diff --git a/Runtime/Security/Asn1Processor/Oid.cs b/Runtime/Security/Asn1Processor/Oid.cs index 5efd9e2..4c29f1a 100644 --- a/Runtime/Security/Asn1Processor/Oid.cs +++ b/Runtime/Security/Asn1Processor/Oid.cs @@ -18,7 +18,7 @@ //| this list of conditions and the following disclaimer in the documentation | //| and/or other materials provided with the distribution. | //| | -//| THE SOFTWARE PRODUCT IS PROVIDED AS IS WITHOUT WARRANTY OF ANY KIND, | +//| THE SOFTWARE PRODUCT IS PROVIDED �AS IS� WITHOUT WARRANTY OF ANY KIND, | //| EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | //| WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS FOR | //| A PARTICULAR PURPOSE. | @@ -30,155 +30,154 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// Summary description for OID. - /// This class is used to encode and decode OID strings. - /// - internal class Oid - { - /// - /// Retrieve OID name by OID string. - /// - /// source OID string. - /// OID name. - public string GetOidName(string inOidStr) - { - if (oidDictionary == null) //Initialize oidDictionary: - { - oidDictionary = new StringDictionary(); -// string oidStr = ""; -// string oidDesc = ""; -// bool loadOidError = false; -// int dbCounter = 0; - } - return oidDictionary[inOidStr]; - } + /// + /// Summary description for OID. + /// This class is used to encode and decode OID strings. + /// + internal class Oid + { + /// + /// Retrieve OID name by OID string. + /// + /// source OID string. + /// OID name. + public string GetOidName(string inOidStr) + { + if (oidDictionary == null) //Initialize oidDictionary: + { + oidDictionary = new StringDictionary(); + // string oidStr = ""; + // string oidDesc = ""; + // bool loadOidError = false; + // int dbCounter = 0; + } + return oidDictionary[inOidStr]; + } - /// - /// Encode OID string to byte array. - /// - /// source string. - /// encoded array. - public byte[] Encode(string oidStr) - { - MemoryStream ms = new MemoryStream(); - Encode(ms, oidStr); - ms.Position = 0; - byte[] retval = new byte[ms.Length]; - ms.Read(retval, 0, retval.Length); - ms.Close(); - return retval; - } + /// + /// Encode OID string to byte array. + /// + /// source string. + /// encoded array. + public byte[] Encode(string oidStr) + { + MemoryStream ms = new MemoryStream(); + Encode(ms, oidStr); + ms.Position = 0; + byte[] retval = new byte[ms.Length]; + ms.Read(retval, 0, retval.Length); + ms.Close(); + return retval; + } - /// - /// Decode OID byte array to OID string. - /// - /// source byte array. - /// result OID string. - public string Decode(byte[] data) - { - MemoryStream ms = new MemoryStream(data); - ms.Position = 0; - string retval = Decode(ms); - ms.Close(); - return retval; - } - - /// - /// Encode OID string and put result into - /// - /// output stream. - /// source OID string. - public virtual void Encode(Stream bt, string oidStr) //TODO - { - string[] oidList = oidStr.Split('.'); - if (oidList.Length < 2) throw new Exception("Invalid OID string."); - ulong[] values = new ulong[oidList.Length]; - for (int i = 0; i + /// Decode OID byte array to OID string. + /// + /// source byte array. + /// result OID string. + public string Decode(byte[] data) + { + MemoryStream ms = new MemoryStream(data); + ms.Position = 0; + string retval = Decode(ms); + ms.Close(); + return retval; + } - /// - /// Decode OID and return OID string. - /// - /// source stream. - /// result OID string. - public virtual string Decode(Stream bt) - { - string retval = ""; - byte b; - ulong v = 0; - b = (byte) bt.ReadByte(); - retval += Convert.ToString(b/40); - retval += "." + Convert.ToString(b%40); - while (bt.Position < bt.Length) - { - try - { - DecodeValue(bt, ref v); - retval += "." + v.ToString(); - } - catch(Exception e) - { - throw new Exception("Failed to decode OID value: " + e.Message); - } - } - return retval; - } + /// + /// Encode OID string and put result into + /// + /// output stream. + /// source OID string. + public virtual void Encode(Stream bt, string oidStr) //TODO + { + string[] oidList = oidStr.Split('.'); + if (oidList.Length < 2) throw new Exception("Invalid OID string."); + ulong[] values = new ulong[oidList.Length]; + for (int i = 0; i < oidList.Length; i++) + { + values[i] = Convert.ToUInt64(oidList[i]); + } + bt.WriteByte((byte)(values[0] * 40 + values[1])); + for (int i = 2; i < values.Length; i++) + EncodeValue(bt, values[i]); + } - /// - /// OID dictionary. - /// - private static StringDictionary oidDictionary = null; + /// + /// Decode OID and return OID string. + /// + /// source stream. + /// result OID string. + public virtual string Decode(Stream bt) + { + string retval = ""; + byte b; + ulong v = 0; + b = (byte)bt.ReadByte(); + retval += Convert.ToString(b / 40); + retval += "." + Convert.ToString(b % 40); + while (bt.Position < bt.Length) + { + try + { + DecodeValue(bt, ref v); + retval += "." + v.ToString(); + } + catch (Exception e) + { + throw new Exception("Failed to decode OID value: " + e.Message); + } + } + return retval; + } - /// - /// Default constructor - /// - public Oid() - { - } + /// + /// OID dictionary. + /// + private static StringDictionary oidDictionary = null; - /// - /// Encode single OID value. - /// - /// output stream. - /// source value. - protected void EncodeValue(Stream bt, ulong v) - { - for (int i=(Asn1Util.BitPrecision(v)-1)/7; i > 0; i--) - { - bt.WriteByte((byte)(0x80 | ((v >> (i*7)) & 0x7f))); - } - bt.WriteByte((byte)(v & 0x7f)); - } + /// + /// Default constructor + /// + public Oid() + { + } - /// - /// Decode single OID value. - /// - /// source stream. - /// output value - /// OID value bytes. - protected int DecodeValue(Stream bt, ref ulong v) - { - byte b; - int i=0; - v = 0; - while (true) - { - b = (byte) bt.ReadByte(); - i++; - v <<= 7; - v += (ulong) (b & 0x7f); - if ((b & 0x80) == 0) - return i; - } - } + /// + /// Encode single OID value. + /// + /// output stream. + /// source value. + protected void EncodeValue(Stream bt, ulong v) + { + for (int i = (Asn1Util.BitPrecision(v) - 1) / 7; i > 0; i--) + { + bt.WriteByte((byte)(0x80 | ((v >> (i * 7)) & 0x7f))); + } + bt.WriteByte((byte)(v & 0x7f)); + } - } -} + /// + /// Decode single OID value. + /// + /// source stream. + /// output value + /// OID value bytes. + protected int DecodeValue(Stream bt, ref ulong v) + { + byte b; + int i = 0; + v = 0; + while (true) + { + b = (byte)bt.ReadByte(); + i++; + v <<= 7; + v += (ulong)(b & 0x7f); + if ((b & 0x80) == 0) + return i; + } + } + } +} diff --git a/Runtime/Security/Asn1Processor/RelativeOid.cs b/Runtime/Security/Asn1Processor/RelativeOid.cs index e561e43..b28eee2 100644 --- a/Runtime/Security/Asn1Processor/RelativeOid.cs +++ b/Runtime/Security/Asn1Processor/RelativeOid.cs @@ -3,67 +3,67 @@ namespace LipingShare.LCLib.Asn1Processor { - /// - /// Summary description for RelativeOid. - /// - internal class RelativeOid : Oid - { - /// - /// Constructor. - /// - public RelativeOid() - { - } + /// + /// Summary description for RelativeOid. + /// + internal class RelativeOid : Oid + { + /// + /// Constructor. + /// + public RelativeOid() + { + } - /// - /// Encode relative OID string and put result into - /// - /// output stream. - /// source OID string. - public override void Encode(Stream bt, string oidStr) - { - string[] oidList = oidStr.Split('.'); - ulong[] values = new ulong[oidList.Length]; - for (int i = 0; i + /// Encode relative OID string and put result into + /// + /// output stream. + /// source OID string. + public override void Encode(Stream bt, string oidStr) + { + string[] oidList = oidStr.Split('.'); + ulong[] values = new ulong[oidList.Length]; + for (int i = 0; i < oidList.Length; i++) + { + values[i] = Convert.ToUInt64(oidList[i]); + } + for (int i = 0; i < values.Length; i++) + EncodeValue(bt, values[i]); + } - /// - /// Decode relative OID and return OID string. - /// - /// source stream. - /// result OID string. - public override string Decode(Stream bt) - { - string retval = ""; - ulong v = 0; - bool isFirst = true; - while (bt.Position < bt.Length) - { - try - { - DecodeValue(bt, ref v); - if (isFirst) - { - retval = v.ToString(); - isFirst = false; - } - else - { - retval += "." + v.ToString(); - } - } - catch(Exception e) - { - throw new Exception("Failed to decode OID value: " + e.Message); - } - } - return retval; - } + /// + /// Decode relative OID and return OID string. + /// + /// source stream. + /// result OID string. + public override string Decode(Stream bt) + { + string retval = ""; + ulong v = 0; + bool isFirst = true; + while (bt.Position < bt.Length) + { + try + { + DecodeValue(bt, ref v); + if (isFirst) + { + retval = v.ToString(); + isFirst = false; + } + else + { + retval += "." + v.ToString(); + } + } + catch (Exception e) + { + throw new Exception("Failed to decode OID value: " + e.Message); + } + } + return retval; + } - } + } } diff --git a/Runtime/Security/Asn1Processor/Util.cs b/Runtime/Security/Asn1Processor/Util.cs index bb65e38..9226b04 100644 --- a/Runtime/Security/Asn1Processor/Util.cs +++ b/Runtime/Security/Asn1Processor/Util.cs @@ -3,72 +3,72 @@ namespace LCLib.Asn1Processor { - /// - /// Summary description for Util. - /// - internal class Asn1Util - { - public static int BytePrecision(ulong value) - { - int i; - for (i=sizeof(ulong); i>0; --i) - if ((value >> (i-1)*8)!=0) - break; - return i; - } + /// + /// Summary description for Util. + /// + internal class Asn1Util + { + public static int BytePrecision(ulong value) + { + int i; + for (i = sizeof(ulong); i > 0; --i) + if ((value >> (i - 1) * 8) != 0) + break; + return i; + } - public static int DERLengthEncode(Stream xdata, ulong length) - { - int i=0; - if (length <= 0x7f) - { - xdata.WriteByte((byte)length); - i++; - } - else - { - xdata.WriteByte((byte)(BytePrecision(length) | 0x80)); - i++; - for (int j=BytePrecision((ulong)length); j>0; --j) - { - xdata.WriteByte((byte)(length >> (j-1)*8)); - i++; - } - } - return i; - } + public static int DERLengthEncode(Stream xdata, ulong length) + { + int i = 0; + if (length <= 0x7f) + { + xdata.WriteByte((byte)length); + i++; + } + else + { + xdata.WriteByte((byte)(BytePrecision(length) | 0x80)); + i++; + for (int j = BytePrecision((ulong)length); j > 0; --j) + { + xdata.WriteByte((byte)(length >> (j - 1) * 8)); + i++; + } + } + return i; + } - public static long DerLengthDecode(Stream bt) - { - long length = 0; - byte b; - b = (byte) bt.ReadByte(); - if ((b & 0x80)==0) - { - length = b; - } - else - { - long lengthBytes = b & 0x7f; - if (lengthBytes == 0) - { - throw new Exception("Indefinite length."); - } - length = 0; - while (lengthBytes-- > 0) - { - if ((length >> (8*(sizeof(long)-1))) > 0) - throw new Exception("Length overflow."); - b = (byte) bt.ReadByte(); - length = (length << 8) | b; - } - } - return length; - } + public static long DerLengthDecode(Stream bt) + { + long length = 0; + byte b; + b = (byte)bt.ReadByte(); + if ((b & 0x80) == 0) + { + length = b; + } + else + { + long lengthBytes = b & 0x7f; + if (lengthBytes == 0) + { + throw new Exception("Indefinite length."); + } + length = 0; + while (lengthBytes-- > 0) + { + if ((length >> (8 * (sizeof(long) - 1))) > 0) + throw new Exception("Length overflow."); + b = (byte)bt.ReadByte(); + length = (length << 8) | b; + } + } + return length; + } - private Asn1Util() - { - } + private Asn1Util() + { + } - } + } } diff --git a/Runtime/Security/AssemblyInfo.cs b/Runtime/Security/AssemblyInfo.cs index 3bfda1a..25c561c 100644 --- a/Runtime/Security/AssemblyInfo.cs +++ b/Runtime/Security/AssemblyInfo.cs @@ -1,7 +1,7 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("specs")] +[assembly: InternalsVisibleTo("specs")] [assembly: InternalsVisibleTo("UnityEngine.Purchasing.EditorTests")] [assembly: InternalsVisibleTo("UnityEngine.Purchasing.RuntimeTests")] diff --git a/Runtime/Security/Certificate.cs b/Runtime/Security/Certificate.cs index a15f6be..89097a1 100644 --- a/Runtime/Security/Certificate.cs +++ b/Runtime/Security/Certificate.cs @@ -4,128 +4,142 @@ using LipingShare.LCLib.Asn1Processor; using System.Security.Cryptography; -namespace UnityEngine.Purchasing.Security { - - internal class DistinguishedName { - public string Country { get; set; } - public string Organization { get; set; } - public string OrganizationalUnit { get; set; } - public string Dnq { get; set; } - public string State { get; set; } - public string CommonName { get; set; } - public string SerialNumber { get; set; } - - public DistinguishedName(Asn1Node n) { - /* Name: - * SET - * SEQ (attr) - * Object Identifier - * Printable String || UTF8String - */ - if (n.MaskedTag == Asn1Tag.SEQUENCE) { - for (int i=0; i < n.ChildNodeCount; i++) { - Asn1Node tt = n.GetChildNode(i); - if (tt.MaskedTag != Asn1Tag.SET || tt.ChildNodeCount != 1) - throw new InvalidX509Data(); - - tt = tt.GetChildNode(0); - if (tt.MaskedTag != Asn1Tag.SEQUENCE || tt.ChildNodeCount != 2) - throw new InvalidX509Data(); - - Asn1Node oi = tt.GetChildNode(0); - Asn1Node txt = tt.GetChildNode(1); - - if (oi.MaskedTag != Asn1Tag.OBJECT_IDENTIFIER || - !( - (txt.MaskedTag == Asn1Tag.PRINTABLE_STRING) || - (txt.MaskedTag == Asn1Tag.UTF8_STRING) || +namespace UnityEngine.Purchasing.Security +{ + + internal class DistinguishedName + { + public string Country { get; set; } + public string Organization { get; set; } + public string OrganizationalUnit { get; set; } + public string Dnq { get; set; } + public string State { get; set; } + public string CommonName { get; set; } + public string SerialNumber { get; set; } + + public DistinguishedName(Asn1Node n) + { + /* Name: + * SET + * SEQ (attr) + * Object Identifier + * Printable String || UTF8String + */ + if (n.MaskedTag == Asn1Tag.SEQUENCE) + { + for (int i = 0; i < n.ChildNodeCount; i++) + { + Asn1Node tt = n.GetChildNode(i); + if (tt.MaskedTag != Asn1Tag.SET || tt.ChildNodeCount != 1) + throw new InvalidX509Data(); + + tt = tt.GetChildNode(0); + if (tt.MaskedTag != Asn1Tag.SEQUENCE || tt.ChildNodeCount != 2) + throw new InvalidX509Data(); + + Asn1Node oi = tt.GetChildNode(0); + Asn1Node txt = tt.GetChildNode(1); + + if (oi.MaskedTag != Asn1Tag.OBJECT_IDENTIFIER || + !( + (txt.MaskedTag == Asn1Tag.PRINTABLE_STRING) || + (txt.MaskedTag == Asn1Tag.UTF8_STRING) || (txt.MaskedTag == Asn1Tag.IA5_STRING))) - { - throw new InvalidX509Data(); - } - var xoid = new LipingShare.LCLib.Asn1Processor.Oid(); - string oiName = xoid.Decode(oi.Data); - var enc = new System.Text.UTF8Encoding(); - - switch (oiName) { - case "2.5.4.6": // countryName - Country = enc.GetString(txt.Data); - break; - case "2.5.4.10": // organizationName - Organization = enc.GetString(txt.Data); - break; - case "2.5.4.11": // organizationalUnit - OrganizationalUnit = enc.GetString(txt.Data); - break; - case "2.5.4.3": // commonName - CommonName = enc.GetString(txt.Data); - break; - case "2.5.4.5": // serial number - SerialNumber = Asn1Util.ToHexString(txt.Data); - break; - case "2.5.4.46": // dnq - Dnq = enc.GetString(txt.Data); - break; - case "2.5.4.8": // state - State = enc.GetString(txt.Data); - break; - } - } - } - } - - public bool Equals(DistinguishedName n2) { - return this.Organization == n2.Organization && - this.OrganizationalUnit == n2.OrganizationalUnit && - this.Dnq == n2.Dnq && - this.Country == n2.Country && - this.State == n2.State && - this.CommonName == n2.CommonName; - } - - public override string ToString() { - return "CN: " + CommonName + "\n" + - "ON: " + Organization + "\n" + - "Unit Name: " + OrganizationalUnit + "\n" + - "Country: " + Country; - } - } - - internal class X509Cert { - public string SerialNumber { get; private set; } - public DateTime ValidAfter { get; private set; } - public DateTime ValidBefore { get; private set; } - public RSAKey PubKey { get; private set; } - public bool SelfSigned { get; private set; } - public DistinguishedName Subject { get; private set; } - public DistinguishedName Issuer { get; private set; } - private Asn1Node TbsCertificate; - public Asn1Node Signature { get; private set; } - public byte[] rawTBSCertificate; - - public X509Cert(Asn1Node n) { - ParseNode(n); - } - - public X509Cert(byte[] data) { - using (var stm = new System.IO.MemoryStream(data)) - { - Asn1Parser parser = new Asn1Parser(); - parser.LoadData(stm); - ParseNode(parser.RootNode); - } - } - - public bool CheckCertTime(DateTime time) { - return time.CompareTo(ValidAfter) >=0 && time.CompareTo(ValidBefore) <= 0; - } - - public bool CheckSignature(X509Cert signer) { - if (Issuer.Equals(signer.Subject)) { - return signer.PubKey.Verify(rawTBSCertificate, Signature.Data); - } - return false; - } + { + throw new InvalidX509Data(); + } + var xoid = new LipingShare.LCLib.Asn1Processor.Oid(); + string oiName = xoid.Decode(oi.Data); + var enc = new System.Text.UTF8Encoding(); + + switch (oiName) + { + case "2.5.4.6": // countryName + Country = enc.GetString(txt.Data); + break; + case "2.5.4.10": // organizationName + Organization = enc.GetString(txt.Data); + break; + case "2.5.4.11": // organizationalUnit + OrganizationalUnit = enc.GetString(txt.Data); + break; + case "2.5.4.3": // commonName + CommonName = enc.GetString(txt.Data); + break; + case "2.5.4.5": // serial number + SerialNumber = Asn1Util.ToHexString(txt.Data); + break; + case "2.5.4.46": // dnq + Dnq = enc.GetString(txt.Data); + break; + case "2.5.4.8": // state + State = enc.GetString(txt.Data); + break; + } + } + } + } + + public bool Equals(DistinguishedName n2) + { + return this.Organization == n2.Organization && + this.OrganizationalUnit == n2.OrganizationalUnit && + this.Dnq == n2.Dnq && + this.Country == n2.Country && + this.State == n2.State && + this.CommonName == n2.CommonName; + } + + public override string ToString() + { + return "CN: " + CommonName + "\n" + + "ON: " + Organization + "\n" + + "Unit Name: " + OrganizationalUnit + "\n" + + "Country: " + Country; + } + } + + internal class X509Cert + { + public string SerialNumber { get; private set; } + public DateTime ValidAfter { get; private set; } + public DateTime ValidBefore { get; private set; } + public RSAKey PubKey { get; private set; } + public bool SelfSigned { get; private set; } + public DistinguishedName Subject { get; private set; } + public DistinguishedName Issuer { get; private set; } + private Asn1Node TbsCertificate; + public Asn1Node Signature { get; private set; } + public byte[] rawTBSCertificate; + + public X509Cert(Asn1Node n) + { + ParseNode(n); + } + + public X509Cert(byte[] data) + { + using (var stm = new System.IO.MemoryStream(data)) + { + Asn1Parser parser = new Asn1Parser(); + parser.LoadData(stm); + ParseNode(parser.RootNode); + } + } + + public bool CheckCertTime(DateTime time) + { + return time.CompareTo(ValidAfter) >= 0 && time.CompareTo(ValidBefore) <= 0; + } + + public bool CheckSignature(X509Cert signer) + { + if (Issuer.Equals(signer.Subject)) + { + return signer.PubKey.Verify(rawTBSCertificate, Signature.Data); + } + return false; + } public bool CheckSignature256(X509Cert signer) { @@ -137,95 +151,100 @@ public bool CheckSignature256(X509Cert signer) return false; } - private void ParseNode(Asn1Node root) { - if ((root.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || root.ChildNodeCount != 3) - throw new InvalidX509Data(); + private void ParseNode(Asn1Node root) + { + if ((root.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || root.ChildNodeCount != 3) + throw new InvalidX509Data(); - // TBS cert - TbsCertificate = root.GetChildNode(0); - if (TbsCertificate.ChildNodeCount < 7) - throw new InvalidX509Data(); + // TBS cert + TbsCertificate = root.GetChildNode(0); + if (TbsCertificate.ChildNodeCount < 7) + throw new InvalidX509Data(); - rawTBSCertificate = new byte[TbsCertificate.DataLength + 4]; - Array.Copy (root.Data, 0, rawTBSCertificate, 0, rawTBSCertificate.Length); + rawTBSCertificate = new byte[TbsCertificate.DataLength + 4]; + Array.Copy(root.Data, 0, rawTBSCertificate, 0, rawTBSCertificate.Length); - // get the serial number - Asn1Node sn = TbsCertificate.GetChildNode(1); - if ((sn.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) - throw new InvalidX509Data(); - SerialNumber = Asn1Util.ToHexString(sn.Data); + // get the serial number + Asn1Node sn = TbsCertificate.GetChildNode(1); + if ((sn.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) + throw new InvalidX509Data(); + SerialNumber = Asn1Util.ToHexString(sn.Data); - // get the issuer - Issuer = new DistinguishedName(TbsCertificate.GetChildNode(3)); + // get the issuer + Issuer = new DistinguishedName(TbsCertificate.GetChildNode(3)); - // get the subject - Subject = new DistinguishedName(TbsCertificate.GetChildNode(5)); + // get the subject + Subject = new DistinguishedName(TbsCertificate.GetChildNode(5)); - // get the dates - Asn1Node validTimes = TbsCertificate.GetChildNode(4); - if ((validTimes.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || validTimes.ChildNodeCount != 2) - throw new InvalidX509Data(); - ValidAfter = ParseTime(validTimes.GetChildNode(0)); - ValidBefore = ParseTime(validTimes.GetChildNode(1)); + // get the dates + Asn1Node validTimes = TbsCertificate.GetChildNode(4); + if ((validTimes.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || validTimes.ChildNodeCount != 2) + throw new InvalidX509Data(); + ValidAfter = ParseTime(validTimes.GetChildNode(0)); + ValidBefore = ParseTime(validTimes.GetChildNode(1)); - // is this self signed? - SelfSigned = Subject.Equals(Issuer); + // is this self signed? + SelfSigned = Subject.Equals(Issuer); - // get the pub key - PubKey = new RSAKey(TbsCertificate.GetChildNode(6)); + // get the pub key + PubKey = new RSAKey(TbsCertificate.GetChildNode(6)); - // set the tbs cert & signature data for signature verification - Signature = root.GetChildNode(2); - } + // set the tbs cert & signature data for signature verification + Signature = root.GetChildNode(2); + } - /** - * According to rfc5280, time should be specified in GMT: - * https://tools.ietf.org/html/rfc5280#section-4.1.2.5 - */ - private DateTime ParseTime(Asn1Node n) { - string time = (new System.Text.UTF8Encoding()).GetString(n.Data); + /** + * According to rfc5280, time should be specified in GMT: + * https://tools.ietf.org/html/rfc5280#section-4.1.2.5 + */ + private DateTime ParseTime(Asn1Node n) + { + string time = (new System.Text.UTF8Encoding()).GetString(n.Data); - if (!(time.Length == 13 || time.Length == 15)) - throw new InvalidTimeFormat(); + if (!(time.Length == 13 || time.Length == 15)) + throw new InvalidTimeFormat(); - // only accept Zulu time - if (time[time.Length - 1] != 'Z') - throw new InvalidTimeFormat(); + // only accept Zulu time + if (time[time.Length - 1] != 'Z') + throw new InvalidTimeFormat(); - int curIdx = 0; + int curIdx = 0; - int year = 0; - if (time.Length == 13) { - year = Int32.Parse(time.Substring(0,2)); - if (year >= 50) - year += 1900; - else if (year < 50) - year += 2000; - curIdx += 2; - } else { - year = Int32.Parse(time.Substring(0,4)); - curIdx += 4; - } + int year = 0; + if (time.Length == 13) + { + year = Int32.Parse(time.Substring(0, 2)); + if (year >= 50) + year += 1900; + else if (year < 50) + year += 2000; + curIdx += 2; + } + else + { + year = Int32.Parse(time.Substring(0, 4)); + curIdx += 4; + } - int month = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; - int dom = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; - int hour = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; - int min = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; - int secs = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; + int month = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; + int dom = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; + int hour = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; + int min = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; + int secs = Int32.Parse(time.Substring(curIdx, 2)); curIdx += 2; - return new DateTime(year, month, dom, hour, min, secs, DateTimeKind.Utc); - } - } + return new DateTime(year, month, dom, hour, min, secs, DateTimeKind.Utc); + } + } /// /// An IAP Security exception indicating some invalid time format. /// - public class InvalidTimeFormat : IAPSecurityException {} + public class InvalidTimeFormat : IAPSecurityException { } /// /// An IAP Security exception indicating some invalid data for X509 certification checks. /// - public class InvalidX509Data : IAPSecurityException {} + public class InvalidX509Data : IAPSecurityException { } } diff --git a/Runtime/Security/CrossPlatformValidator.cs b/Runtime/Security/CrossPlatformValidator.cs index 03f6cb9..bc77d0d 100644 --- a/Runtime/Security/CrossPlatformValidator.cs +++ b/Runtime/Security/CrossPlatformValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using UnityEngine.Purchasing; @@ -14,7 +14,7 @@ public class StoreNotSupportedException : IAPSecurityException /// Constructs an instance with a message. /// /// The message that describes the error. - public StoreNotSupportedException (string message) : base(message) + public StoreNotSupportedException(string message) : base(message) { } } @@ -76,11 +76,11 @@ public GenericValidationException(string message) : base(message) /// Class that validates receipts on multiple platforms that support the Security module. /// Note that this currently only supports GooglePlay and Apple platforms. /// - public class CrossPlatformValidator - { - private GooglePlayValidator google; - private AppleValidator apple; - private string googleBundleId, appleBundleId; + public class CrossPlatformValidator + { + private GooglePlayValidator google; + private AppleValidator apple; + private string googleBundleId, appleBundleId; /// /// Constructs an instance and checks the validity of the certification keys @@ -89,10 +89,10 @@ public class CrossPlatformValidator /// The GooglePlay public key. /// The Apple certification key. /// The bundle ID for all platforms. - public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, - string appBundleId) : this(googlePublicKey, appleRootCert, null, appBundleId, appBundleId, null) - { - } + public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, + string appBundleId) : this(googlePublicKey, appleRootCert, null, appBundleId, appBundleId, null) + { + } /// /// Constructs an instance and checks the validity of the certification keys @@ -102,10 +102,11 @@ public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, /// The Apple certification key. /// The Unity Channel public key. Not used because Unity Channel is no longer supported. /// The bundle ID for all platforms. - public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, byte[] unityChannelPublicKey_not_used, - string appBundleId) - : this(googlePublicKey, appleRootCert, null, appBundleId, appBundleId, appBundleId) { - } + public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, byte[] unityChannelPublicKey_not_used, + string appBundleId) + : this(googlePublicKey, appleRootCert, null, appBundleId, appBundleId, appBundleId) + { + } /// /// Constructs an instance and checks the validity of the certification keys @@ -115,11 +116,11 @@ public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, byte /// The Apple certification key. /// The GooglePlay bundle ID. /// The Apple bundle ID. - public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, - string googleBundleId, string appleBundleId) - : this(googlePublicKey, appleRootCert, null, googleBundleId, appleBundleId, null) - { - } + public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, + string googleBundleId, string appleBundleId) + : this(googlePublicKey, appleRootCert, null, googleBundleId, appleBundleId, null) + { + } /// /// Constructs an instance and checks the validity of the certification keys. @@ -130,101 +131,102 @@ public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, /// The GooglePlay bundle ID. /// The Apple bundle ID. /// The Xiaomi bundle ID. Not used because Xiaomi is no longer supported. - public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, byte[] unityChannelPublicKey_not_used, - string googleBundleId, string appleBundleId, string xiaomiBundleId_not_used) { - try + public CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, byte[] unityChannelPublicKey_not_used, + string googleBundleId, string appleBundleId, string xiaomiBundleId_not_used) + { + try { - if (null != googlePublicKey) + if (null != googlePublicKey) { - google = new GooglePlayValidator(googlePublicKey); - } + google = new GooglePlayValidator(googlePublicKey); + } - if (null != appleRootCert) + if (null != appleRootCert) { - apple = new AppleValidator(appleRootCert); - } - } + apple = new AppleValidator(appleRootCert); + } + } catch (Exception ex) { - throw new InvalidPublicKeyException ("Cannot instantiate self with an invalid public key. (" + - ex.ToString () + ")"); - } + throw new InvalidPublicKeyException("Cannot instantiate self with an invalid public key. (" + + ex.ToString() + ")"); + } - this.googleBundleId = googleBundleId; - this.appleBundleId = appleBundleId; - } + this.googleBundleId = googleBundleId; + this.appleBundleId = appleBundleId; + } /// /// Validates a receipt. /// /// The receipt to be validated. /// An array of receipts parsed from the validation process - public IPurchaseReceipt[] Validate(string unityIAPReceipt) + public IPurchaseReceipt[] Validate(string unityIAPReceipt) { - try + try { - var wrapper = (Dictionary) MiniJson.JsonDecode (unityIAPReceipt); - if (null == wrapper) + var wrapper = (Dictionary)MiniJson.JsonDecode(unityIAPReceipt); + if (null == wrapper) { - throw new InvalidReceiptDataException (); - } + throw new InvalidReceiptDataException(); + } - var store = (string)wrapper ["Store"]; - var payload = (string)wrapper ["Payload"]; + var store = (string)wrapper["Store"]; + var payload = (string)wrapper["Payload"]; - switch (store) + switch (store) { - case "GooglePlay": - { - if (null == google) - { - throw new MissingStoreSecretException( - "Cannot validate a Google Play receipt without a Google Play public key."); - } - var details = (Dictionary) MiniJson.JsonDecode(payload); - var json = (string) details["json"]; - var sig = (string) details["signature"]; - var result = google.Validate(json, sig); - - // [IAP-1696] Check googleBundleId if packageName is present inside the signed receipt. - // packageName can be missing when the GPB v1 getPurchaseHistory API is used to fetch. - if (!string.IsNullOrEmpty(result.packageName) && - !googleBundleId.Equals(result.packageName)) - { - throw new InvalidBundleIdException(); - } - - return new IPurchaseReceipt[] {result}; - } - case "AppleAppStore": - case "MacAppStore": - { - if (null == apple) - { - throw new MissingStoreSecretException( - "Cannot validate an Apple receipt without supplying an Apple root certificate"); - } - var r = apple.Validate(Convert.FromBase64String(payload)); - if (!appleBundleId.Equals(r.bundleID)) - { - throw new InvalidBundleIdException(); - } - return r.inAppPurchaseReceipts.ToArray(); - } - default: - { - throw new StoreNotSupportedException ("Store not supported: " + store); - } - } - } + case "GooglePlay": + { + if (null == google) + { + throw new MissingStoreSecretException( + "Cannot validate a Google Play receipt without a Google Play public key."); + } + var details = (Dictionary)MiniJson.JsonDecode(payload); + var json = (string)details["json"]; + var sig = (string)details["signature"]; + var result = google.Validate(json, sig); + + // [IAP-1696] Check googleBundleId if packageName is present inside the signed receipt. + // packageName can be missing when the GPB v1 getPurchaseHistory API is used to fetch. + if (!string.IsNullOrEmpty(result.packageName) && + !googleBundleId.Equals(result.packageName)) + { + throw new InvalidBundleIdException(); + } + + return new IPurchaseReceipt[] { result }; + } + case "AppleAppStore": + case "MacAppStore": + { + if (null == apple) + { + throw new MissingStoreSecretException( + "Cannot validate an Apple receipt without supplying an Apple root certificate"); + } + var r = apple.Validate(Convert.FromBase64String(payload)); + if (!appleBundleId.Equals(r.bundleID)) + { + throw new InvalidBundleIdException(); + } + return r.inAppPurchaseReceipts.ToArray(); + } + default: + { + throw new StoreNotSupportedException("Store not supported: " + store); + } + } + } catch (IAPSecurityException ex) { - throw ex; - } + throw ex; + } catch (Exception ex) { - throw new GenericValidationException ("Cannot validate due to unhandled exception. ("+ex+")"); - } - } - } + throw new GenericValidationException("Cannot validate due to unhandled exception. (" + ex + ")"); + } + } + } } diff --git a/Runtime/Security/GooglePlayReceipt.cs b/Runtime/Security/GooglePlayReceipt.cs index b4bb832..c5e8b45 100644 --- a/Runtime/Security/GooglePlayReceipt.cs +++ b/Runtime/Security/GooglePlayReceipt.cs @@ -1,45 +1,45 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { - // See Google's reference docs. - // http://developer.android.com/google/play/billing/billing_reference.html + // See Google's reference docs. + // http://developer.android.com/google/play/billing/billing_reference.html /// /// The state of the GooglePlay purchase. /// - public enum GooglePurchaseState + public enum GooglePurchaseState { /// /// The purchase was completed. /// - Purchased, + Purchased, /// /// The purchase was cancelled. /// - Cancelled, + Cancelled, /// /// The purchase was refunded. /// - Refunded - } + Refunded + } /// /// A GooglePlay purchase receipt /// - public class GooglePlayReceipt : IPurchaseReceipt - { + public class GooglePlayReceipt : IPurchaseReceipt + { /// /// The item's product identifier. /// - public string productID { get; private set; } + public string productID { get; private set; } /// /// A unique order identifier for the transaction. This identifier corresponds to the Google payments order ID. /// - public string orderID { get; private set; } + public string orderID { get; private set; } /// /// The ID of the transaction. @@ -49,22 +49,22 @@ public class GooglePlayReceipt : IPurchaseReceipt /// /// The package name of the app. /// - public string packageName { get; private set; } + public string packageName { get; private set; } /// /// A token that uniquely identifies a purchase for a given item and user pair. /// - public string purchaseToken { get; private set; } + public string purchaseToken { get; private set; } /// /// The time the product was purchased, in milliseconds since the epoch (Jan 1, 1970). /// - public DateTime purchaseDate { get; private set; } + public DateTime purchaseDate { get; private set; } /// /// The purchase state of the order. /// - public GooglePurchaseState purchaseState { get; private set; } + public GooglePurchaseState purchaseState { get; private set; } /// /// Constructor that initializes the members from the input parameters. @@ -75,14 +75,15 @@ public class GooglePlayReceipt : IPurchaseReceipt /// The token that uniquely identifies a purchase for a given item and user pair. /// The time the product was purchased, in milliseconds since the epoch (Jan 1, 1970). /// The purchase state of the order. - public GooglePlayReceipt(string productID, string orderID, string packageName, - string purchaseToken, DateTime purchaseTime, GooglePurchaseState purchaseState) { - this.productID = productID; + public GooglePlayReceipt(string productID, string orderID, string packageName, + string purchaseToken, DateTime purchaseTime, GooglePurchaseState purchaseState) + { + this.productID = productID; this.orderID = orderID; - this.packageName = packageName; - this.purchaseToken = purchaseToken; - this.purchaseDate = purchaseTime; - this.purchaseState = purchaseState; - } + this.packageName = packageName; + this.purchaseToken = purchaseToken; + this.purchaseDate = purchaseTime; + this.purchaseState = purchaseState; + } } } diff --git a/Runtime/Security/GooglePlayValidator.cs b/Runtime/Security/GooglePlayValidator.cs index ab68071..291b014 100644 --- a/Runtime/Security/GooglePlayValidator.cs +++ b/Runtime/Security/GooglePlayValidator.cs @@ -1,44 +1,46 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine.Purchasing; using System.Security.Cryptography; namespace UnityEngine.Purchasing.Security { - internal class GooglePlayValidator - { - private RSAKey key; - public GooglePlayValidator (byte[] rsaKey) - { - key = new RSAKey (rsaKey); - } + internal class GooglePlayValidator + { + private RSAKey key; + public GooglePlayValidator(byte[] rsaKey) + { + key = new RSAKey(rsaKey); + } - public GooglePlayReceipt Validate(string receipt, string signature) { - var rawReceipt = System.Text.Encoding.UTF8.GetBytes(receipt); // "{\"orderId\":\"G... - var rawSignature = System.Convert.FromBase64String(signature); + public GooglePlayReceipt Validate(string receipt, string signature) + { + var rawReceipt = System.Text.Encoding.UTF8.GetBytes(receipt); // "{\"orderId\":\"G... + var rawSignature = System.Convert.FromBase64String(signature); - if (!key.Verify (rawReceipt, rawSignature)) { - throw new InvalidSignatureException (); - } + if (!key.Verify(rawReceipt, rawSignature)) + { + throw new InvalidSignatureException(); + } - var dic = (Dictionary)MiniJson.JsonDecode (receipt); - object orderID, packageName, productId, purchaseToken, purchaseTime, purchaseState; + var dic = (Dictionary)MiniJson.JsonDecode(receipt); + object orderID, packageName, productId, purchaseToken, purchaseTime, purchaseState; - dic.TryGetValue ("orderId", out orderID); - dic.TryGetValue ("packageName", out packageName); - dic.TryGetValue ("productId", out productId); - dic.TryGetValue ("purchaseToken", out purchaseToken); - dic.TryGetValue ("purchaseTime", out purchaseTime); - dic.TryGetValue ("purchaseState", out purchaseState); + dic.TryGetValue("orderId", out orderID); + dic.TryGetValue("packageName", out packageName); + dic.TryGetValue("productId", out productId); + dic.TryGetValue("purchaseToken", out purchaseToken); + dic.TryGetValue("purchaseTime", out purchaseTime); + dic.TryGetValue("purchaseState", out purchaseState); - // Google specifies times in milliseconds since 1970. - var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - // NOTE: to safely handle null values for these fields, using Convert.ToDouble & ToInt32 in place of casts - var time = epoch.AddMilliseconds(Convert.ToDouble(purchaseTime)); - var state = (GooglePurchaseState)Convert.ToInt32(purchaseState); + // Google specifies times in milliseconds since 1970. + var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + // NOTE: to safely handle null values for these fields, using Convert.ToDouble & ToInt32 in place of casts + var time = epoch.AddMilliseconds(Convert.ToDouble(purchaseTime)); + var state = (GooglePurchaseState)Convert.ToInt32(purchaseState); - return new GooglePlayReceipt ((string) productId, (string) orderID, (string) packageName, - (string) purchaseToken, time, state); - } - } + return new GooglePlayReceipt((string)productId, (string)orderID, (string)packageName, + (string)purchaseToken, time, state); + } + } } diff --git a/Runtime/Security/Obfuscator.cs b/Runtime/Security/Obfuscator.cs index 6582bf6..e06744c 100644 --- a/Runtime/Security/Obfuscator.cs +++ b/Runtime/Security/Obfuscator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; @@ -28,8 +28,8 @@ public static byte[] DeObfuscate(byte[] data, int[] order, int key) var j = order[i]; int sliceSize = (hasRemainder && j == slices - 1) ? (data.Length % 20) : 20; var tmp = res.Skip(i * 20).Take(sliceSize).ToArray(); // tmp = res[i*20 .. slice] - Array.Copy(res, j * 20, res, i * 20, sliceSize); // res[i] = res[j*20 .. slice] - Array.Copy(tmp, 0, res, j * 20, sliceSize); // res[j] = tmp + Array.Copy(res, j * 20, res, i * 20, sliceSize); // res[i] = res[j*20 .. slice] + Array.Copy(tmp, 0, res, j * 20, sliceSize); // res[j] = tmp } return res.Select(x => (byte)(x ^ key)).ToArray(); } diff --git a/Runtime/Security/PKCS7.cs b/Runtime/Security/PKCS7.cs index bd77f6a..a0084b2 100644 --- a/Runtime/Security/PKCS7.cs +++ b/Runtime/Security/PKCS7.cs @@ -1,45 +1,56 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using LipingShare.LCLib.Asn1Processor; using System.Security.Cryptography; -namespace UnityEngine.Purchasing.Security { - internal class PKCS7 { - private Asn1Node root; - public Asn1Node data { get; private set; } - public List sinfos { get; private set; } - public List certChain { get; private set; } - - private bool validStructure; - - public static PKCS7 Load(byte[] data) { - using (var stm = new System.IO.MemoryStream(data)) { - Asn1Parser parser = new Asn1Parser(); - parser.LoadData(stm); - return new PKCS7(parser.RootNode); - } - } - - public PKCS7(Asn1Node node) { - this.root = node; - CheckStructure(); - } - - public bool Verify(X509Cert cert, DateTime certificateCreationTime) { - if (validStructure) { - bool ok = true; - foreach (var sinfo in sinfos) { - X509Cert signCert = null; - foreach (var c in certChain) { - if (c.SerialNumber == sinfo.IssuerSerialNumber) { - signCert = c; - break; - } - } - - if (signCert != null && signCert.PubKey != null) { - ok = ok && signCert.CheckCertTime (certificateCreationTime); +namespace UnityEngine.Purchasing.Security +{ + internal class PKCS7 + { + private Asn1Node root; + public Asn1Node data { get; private set; } + public List sinfos { get; private set; } + public List certChain { get; private set; } + + private bool validStructure; + + public static PKCS7 Load(byte[] data) + { + using (var stm = new System.IO.MemoryStream(data)) + { + Asn1Parser parser = new Asn1Parser(); + parser.LoadData(stm); + return new PKCS7(parser.RootNode); + } + } + + public PKCS7(Asn1Node node) + { + this.root = node; + CheckStructure(); + } + + public bool Verify(X509Cert cert, DateTime certificateCreationTime) + { + if (validStructure) + { + bool ok = true; + foreach (var sinfo in sinfos) + { + X509Cert signCert = null; + foreach (var c in certChain) + { + if (c.SerialNumber == sinfo.IssuerSerialNumber) + { + signCert = c; + break; + } + } + + if (signCert != null && signCert.PubKey != null) + { + ok = ok && signCert.CheckCertTime(certificateCreationTime); if (IsStoreKitSimulatorData()) { @@ -70,128 +81,139 @@ bool ValidateStorekitSimulatorCertRoot(X509Cert root, X509Cert cert) return cert.CheckSignature256(root); } - private bool ValidateChain(X509Cert root, X509Cert cert, DateTime certificateCreationTime) { - if (cert.Issuer.Equals(root.Subject)) - return cert.CheckSignature(root); - - /** - * TODO: improve this logic - */ - foreach (var c in certChain) { - if (c != cert && c.Subject.Equals(cert.Issuer) && c.CheckCertTime(certificateCreationTime)) { - if (c.Issuer.Equals(root.Subject) && c.SerialNumber == root.SerialNumber) - return c.CheckSignature (root); - else { - // cert was issued by c - if (cert.CheckSignature (c)) - return ValidateChain (root, c, certificateCreationTime); - } - } - } - - return false; - } - - private void CheckStructure() { - validStructure = false; - if ((root.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && - root.ChildNodeCount == 2) - { - Asn1Node tt = root.GetChildNode(0); - if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.OBJECT_IDENTIFIER || - tt.GetDataStr(false) != "1.2.840.113549.1.7.2") { - throw new InvalidPKCS7Data(); - } - - tt = root.GetChildNode(1); // [0] - if (tt.ChildNodeCount != 1) - throw new InvalidPKCS7Data(); - int curChild = 0; - - tt = tt.GetChildNode(curChild++); // Seq - if (tt.ChildNodeCount < 4 || (tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE) - throw new InvalidPKCS7Data(); - - Asn1Node tt2 = tt.GetChildNode(0); // version - if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) - throw new InvalidPKCS7Data(); - - tt2 = tt.GetChildNode(curChild++); // digest algo - // TODO: check algo - if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SET) - throw new InvalidPKCS7Data(); - - tt2 = tt.GetChildNode(curChild++); // pkcs7 data - if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE && tt2.ChildNodeCount != 2) - throw new InvalidPKCS7Data(); - data = tt2.GetChildNode(1).GetChildNode(0); - - if (tt.ChildNodeCount == 5) { - // cert chain, this is optional - certChain = new List(); - tt2 = tt.GetChildNode(curChild++); - if (tt2.ChildNodeCount == 0) - throw new InvalidPKCS7Data(); - for (int i=0; i < tt2.ChildNodeCount; i++) { - certChain.Add(new X509Cert(tt2.GetChildNode(i))); - } - } - - tt2 = tt.GetChildNode(curChild++); // signer's info - if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SET || tt2.ChildNodeCount == 0) - throw new InvalidPKCS7Data(); - - sinfos = new List(); - for (int i=0; i < tt2.ChildNodeCount; i++) { - sinfos.Add(new SignerInfo(tt2.GetChildNode(i))); - } - validStructure = true; - } - } - } - - internal class SignerInfo { - public int Version { get; private set; } - public string IssuerSerialNumber { get; private set; } - public byte[] EncryptedDigest { get; private set; } - - public SignerInfo(Asn1Node n) { - if (n.ChildNodeCount != 5) - throw new InvalidPKCS7Data(); - Asn1Node tt; - - // version - tt = n.GetChildNode(0); - if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) - throw new InvalidPKCS7Data(); - Version = tt.Data[0]; - if (Version != 1 || tt.Data.Length != 1) - throw new UnsupportedSignerInfoVersion(); - - // get the issuer SN - tt = n.GetChildNode(1); - if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || tt.ChildNodeCount != 2) - throw new InvalidPKCS7Data(); - tt = tt.GetChildNode(1); - if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) - throw new InvalidPKCS7Data(); - IssuerSerialNumber = Asn1Util.ToHexString(tt.Data); - - // get the data - tt = n.GetChildNode(4); - if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.OCTET_STRING) - throw new InvalidPKCS7Data(); - EncryptedDigest = tt.Data; - } - } + private bool ValidateChain(X509Cert root, X509Cert cert, DateTime certificateCreationTime) + { + if (cert.Issuer.Equals(root.Subject)) + return cert.CheckSignature(root); + + /** + * TODO: improve this logic + */ + foreach (var c in certChain) + { + if (c != cert && c.Subject.Equals(cert.Issuer) && c.CheckCertTime(certificateCreationTime)) + { + if (c.Issuer.Equals(root.Subject) && c.SerialNumber == root.SerialNumber) + return c.CheckSignature(root); + else + { + // cert was issued by c + if (cert.CheckSignature(c)) + return ValidateChain(root, c, certificateCreationTime); + } + } + } + + return false; + } + + private void CheckStructure() + { + validStructure = false; + if ((root.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && + root.ChildNodeCount == 2) + { + Asn1Node tt = root.GetChildNode(0); + if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.OBJECT_IDENTIFIER || + tt.GetDataStr(false) != "1.2.840.113549.1.7.2") + { + throw new InvalidPKCS7Data(); + } + + tt = root.GetChildNode(1); // [0] + if (tt.ChildNodeCount != 1) + throw new InvalidPKCS7Data(); + int curChild = 0; + + tt = tt.GetChildNode(curChild++); // Seq + if (tt.ChildNodeCount < 4 || (tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE) + throw new InvalidPKCS7Data(); + + Asn1Node tt2 = tt.GetChildNode(0); // version + if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) + throw new InvalidPKCS7Data(); + + tt2 = tt.GetChildNode(curChild++); // digest algo + // TODO: check algo + if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SET) + throw new InvalidPKCS7Data(); + + tt2 = tt.GetChildNode(curChild++); // pkcs7 data + if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE && tt2.ChildNodeCount != 2) + throw new InvalidPKCS7Data(); + data = tt2.GetChildNode(1).GetChildNode(0); + + if (tt.ChildNodeCount == 5) + { + // cert chain, this is optional + certChain = new List(); + tt2 = tt.GetChildNode(curChild++); + if (tt2.ChildNodeCount == 0) + throw new InvalidPKCS7Data(); + for (int i = 0; i < tt2.ChildNodeCount; i++) + { + certChain.Add(new X509Cert(tt2.GetChildNode(i))); + } + } + + tt2 = tt.GetChildNode(curChild++); // signer's info + if ((tt2.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SET || tt2.ChildNodeCount == 0) + throw new InvalidPKCS7Data(); + + sinfos = new List(); + for (int i = 0; i < tt2.ChildNodeCount; i++) + { + sinfos.Add(new SignerInfo(tt2.GetChildNode(i))); + } + validStructure = true; + } + } + } + + internal class SignerInfo + { + public int Version { get; private set; } + public string IssuerSerialNumber { get; private set; } + public byte[] EncryptedDigest { get; private set; } + + public SignerInfo(Asn1Node n) + { + if (n.ChildNodeCount != 5) + throw new InvalidPKCS7Data(); + Asn1Node tt; + + // version + tt = n.GetChildNode(0); + if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) + throw new InvalidPKCS7Data(); + Version = tt.Data[0]; + if (Version != 1 || tt.Data.Length != 1) + throw new UnsupportedSignerInfoVersion(); + + // get the issuer SN + tt = n.GetChildNode(1); + if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.SEQUENCE || tt.ChildNodeCount != 2) + throw new InvalidPKCS7Data(); + tt = tt.GetChildNode(1); + if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.INTEGER) + throw new InvalidPKCS7Data(); + IssuerSerialNumber = Asn1Util.ToHexString(tt.Data); + + // get the data + tt = n.GetChildNode(4); + if ((tt.Tag & Asn1Tag.TAG_MASK) != Asn1Tag.OCTET_STRING) + throw new InvalidPKCS7Data(); + EncryptedDigest = tt.Data; + } + } /// /// An IAP Security exception indicating some invalid data for PKCS7 checks. /// - public class InvalidPKCS7Data : IAPSecurityException {} + public class InvalidPKCS7Data : IAPSecurityException { } /// /// An IAP Security exception indicating unsupported signer information. /// - public class UnsupportedSignerInfoVersion : IAPSecurityException {} + public class UnsupportedSignerInfoVersion : IAPSecurityException { } } diff --git a/Runtime/Security/RSAPubKey.cs b/Runtime/Security/RSAPubKey.cs index 1b06bf9..c563fbb 100644 --- a/Runtime/Security/RSAPubKey.cs +++ b/Runtime/Security/RSAPubKey.cs @@ -1,38 +1,44 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using LipingShare.LCLib.Asn1Processor; using System.Security.Cryptography; -namespace UnityEngine.Purchasing.Security { - internal class RSAKey { - public RSACryptoServiceProvider rsa { get; private set; } +namespace UnityEngine.Purchasing.Security +{ + internal class RSAKey + { + public RSACryptoServiceProvider rsa { get; private set; } - public RSAKey(Asn1Node n) { - rsa = ParseNode (n); - } + public RSAKey(Asn1Node n) + { + rsa = ParseNode(n); + } - public RSAKey(byte[] data) { - using (var stm = new System.IO.MemoryStream(data)) - { - Asn1Parser parser = new Asn1Parser(); - parser.LoadData(stm); - rsa = ParseNode(parser.RootNode); - } - } + public RSAKey(byte[] data) + { + using (var stm = new System.IO.MemoryStream(data)) + { + Asn1Parser parser = new Asn1Parser(); + parser.LoadData(stm); + rsa = ParseNode(parser.RootNode); + } + } - /** - * Public verification of a message - */ - public bool Verify(byte[] message, byte[] signature) { - var sha1hash = new SHA1Managed(); - var msgHash = sha1hash.ComputeHash(message); + /** + * Public verification of a message + */ + public bool Verify(byte[] message, byte[] signature) + { + var sha1hash = new SHA1Managed(); + var msgHash = sha1hash.ComputeHash(message); - // The data is already hashed so we don't need to specify a hashing algorithm. - return rsa.VerifyHash(msgHash, null, signature); - } + // The data is already hashed so we don't need to specify a hashing algorithm. + return rsa.VerifyHash(msgHash, null, signature); + } - public bool Verify256(byte[] message, byte[] signature) { + public bool Verify256(byte[] message, byte[] signature) + { var sha256hash = new SHA256Managed(); var msgHash = sha256hash.ComputeHash(message); @@ -40,43 +46,46 @@ public bool Verify256(byte[] message, byte[] signature) { return rsa.VerifyHash(msgHash, CryptoConfig.MapNameToOID("SHA256"), signature); } - /** - * Parses an DER encoded RSA public key: - * It will only try to get the mod and the exponent - */ - private RSACryptoServiceProvider ParseNode(Asn1Node n) { - if ((n.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && - n.ChildNodeCount == 2 && - (n.GetChildNode(0).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && - (n.GetChildNode(0).GetChildNode(0).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OBJECT_IDENTIFIER && - n.GetChildNode(0).GetChildNode(0).GetDataStr(false) == "1.2.840.113549.1.1.1" && - (n.GetChildNode(1).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.BIT_STRING) - { - var seq = n.GetChildNode(1).GetChildNode(0); - if (seq.ChildNodeCount == 2) { - byte[] data = seq.GetChildNode(0).Data; - byte[] rawMod = new byte[data.Length - 1]; - System.Array.Copy(data, 1, rawMod, 0, data.Length - 1); + /** + * Parses an DER encoded RSA public key: + * It will only try to get the mod and the exponent + */ + private RSACryptoServiceProvider ParseNode(Asn1Node n) + { + if ((n.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && + n.ChildNodeCount == 2 && + (n.GetChildNode(0).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && + (n.GetChildNode(0).GetChildNode(0).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OBJECT_IDENTIFIER && + n.GetChildNode(0).GetChildNode(0).GetDataStr(false) == "1.2.840.113549.1.1.1" && + (n.GetChildNode(1).Tag & Asn1Tag.TAG_MASK) == Asn1Tag.BIT_STRING) + { + var seq = n.GetChildNode(1).GetChildNode(0); + if (seq.ChildNodeCount == 2) + { + byte[] data = seq.GetChildNode(0).Data; + byte[] rawMod = new byte[data.Length - 1]; + System.Array.Copy(data, 1, rawMod, 0, data.Length - 1); - var modulus = System.Convert.ToBase64String(rawMod); - var exponent = System.Convert.ToBase64String(seq.GetChildNode(1).Data); - var result = new RSACryptoServiceProvider (); - result.FromXmlString(ToXML(modulus, exponent)); + var modulus = System.Convert.ToBase64String(rawMod); + var exponent = System.Convert.ToBase64String(seq.GetChildNode(1).Data); + var result = new RSACryptoServiceProvider(); + result.FromXmlString(ToXML(modulus, exponent)); - return result; - } - } - throw new InvalidRSAData(); - } + return result; + } + } + throw new InvalidRSAData(); + } - private string ToXML(string modulus, string exponent) { - return "" + modulus + "" + - "" + exponent + ""; - } - } + private string ToXML(string modulus, string exponent) + { + return "" + modulus + "" + + "" + exponent + ""; + } + } /// /// An IAP Security exception indicating some invalid data parsing an RSA node. /// - public class InvalidRSAData : IAPSecurityException {} + public class InvalidRSAData : IAPSecurityException { } } diff --git a/Runtime/SecurityCore/AppleReceipt.cs b/Runtime/SecurityCore/AppleReceipt.cs index b129c74..5f4afd5 100644 --- a/Runtime/SecurityCore/AppleReceipt.cs +++ b/Runtime/SecurityCore/AppleReceipt.cs @@ -1,99 +1,99 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { - /// - /// An Apple receipt as defined here: - /// https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1 - /// - public class AppleReceipt - { + /// + /// An Apple receipt as defined here: + /// https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW1 + /// + public class AppleReceipt + { /// /// The app bundle ID /// - public string bundleID { get; internal set; } + public string bundleID { get; internal set; } /// /// The app version number /// - public string appVersion { get; internal set; } + public string appVersion { get; internal set; } /// /// The expiration date of the receipt /// - public DateTime expirationDate { get; internal set; } + public DateTime expirationDate { get; internal set; } /// /// An opaque value used, with other data, to compute the SHA-1 hash during validation. /// - public byte[] opaque { get; internal set; } + public byte[] opaque { get; internal set; } /// /// A SHA-1 hash, used to validate the receipt. /// - public byte[] hash { get; internal set; } + public byte[] hash { get; internal set; } /// /// The version of the app that was originally purchased. /// - public string originalApplicationVersion { get; internal set; } + public string originalApplicationVersion { get; internal set; } /// /// The date the receipt was created /// - public DateTime receiptCreationDate { get; internal set; } + public DateTime receiptCreationDate { get; internal set; } /// /// The receipts of the In-App purchases. /// - public AppleInAppPurchaseReceipt[] inAppPurchaseReceipts; - } + public AppleInAppPurchaseReceipt[] inAppPurchaseReceipts; + } - /// - /// The details of an individual purchase. - /// - public class AppleInAppPurchaseReceipt : IPurchaseReceipt + /// + /// The details of an individual purchase. + /// + public class AppleInAppPurchaseReceipt : IPurchaseReceipt { /// /// The number of items purchased. /// - public int quantity { get; internal set; } + public int quantity { get; internal set; } /// /// The product ID /// - public string productID { get; internal set; } + public string productID { get; internal set; } /// /// The ID of the transaction. /// - public string transactionID { get; internal set; } + public string transactionID { get; internal set; } /// /// For a transaction that restores a previous transaction, the transaction ID of the original transaction. Otherwise, identical to the transactionID. /// - public string originalTransactionIdentifier { get; internal set; } + public string originalTransactionIdentifier { get; internal set; } /// /// The date of purchase. /// - public DateTime purchaseDate { get; internal set; } + public DateTime purchaseDate { get; internal set; } /// /// For a transaction that restores a previous transaction, the date of the original transaction. /// - public DateTime originalPurchaseDate { get; internal set; } + public DateTime originalPurchaseDate { get; internal set; } /// /// The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT. /// - public DateTime subscriptionExpirationDate { get; internal set; } + public DateTime subscriptionExpirationDate { get; internal set; } /// /// For a transaction that was canceled by Apple customer support, the time and date of the cancellation. /// For an auto-renewable subscription plan that was upgraded, the time and date of the upgrade transaction. /// - public DateTime cancellationDate { get; internal set; } + public DateTime cancellationDate { get; internal set; } /// /// For a subscription, whether or not it is in the free trial period. @@ -108,7 +108,6 @@ public class AppleInAppPurchaseReceipt : IPurchaseReceipt /// /// For an auto-renewable subscription, whether or not it is in the introductory price period. /// - public int isIntroductoryPricePeriod {get; internal set; } - } + public int isIntroductoryPricePeriod { get; internal set; } + } } - diff --git a/Runtime/SecurityCore/AssemblyInfo.cs b/Runtime/SecurityCore/AssemblyInfo.cs index cd63d8d..73aae97 100644 --- a/Runtime/SecurityCore/AssemblyInfo.cs +++ b/Runtime/SecurityCore/AssemblyInfo.cs @@ -1,6 +1,6 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("UnityEngine.Purchasing.Security")] -[assembly:InternalsVisibleTo("UnityEngine.Purchasing.SecurityStub")] -[assembly:InternalsVisibleTo("specs")] +[assembly: InternalsVisibleTo("UnityEngine.Purchasing.Security")] +[assembly: InternalsVisibleTo("UnityEngine.Purchasing.SecurityStub")] +[assembly: InternalsVisibleTo("specs")] diff --git a/Runtime/SecurityCore/IAPSecurityException.cs b/Runtime/SecurityCore/IAPSecurityException.cs index b4837c8..ee193b4 100644 --- a/Runtime/SecurityCore/IAPSecurityException.cs +++ b/Runtime/SecurityCore/IAPSecurityException.cs @@ -1,27 +1,28 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { /// /// A base exception for IAP Security issues. /// - public class IAPSecurityException : System.Exception + public class IAPSecurityException : System.Exception { /// /// Constructs an instance with no message. /// - public IAPSecurityException() { } + public IAPSecurityException() { } /// /// Constructs an instance with a message. /// /// The message that describes the error. - public IAPSecurityException(string message) : base(message) { - } - } + public IAPSecurityException(string message) : base(message) + { + } + } /// /// An exception for an invalid IAP Security signature. /// - public class InvalidSignatureException : IAPSecurityException {} + public class InvalidSignatureException : IAPSecurityException { } } diff --git a/Runtime/SecurityCore/IPurchaseReceipt.cs b/Runtime/SecurityCore/IPurchaseReceipt.cs index 361225d..1c79033 100644 --- a/Runtime/SecurityCore/IPurchaseReceipt.cs +++ b/Runtime/SecurityCore/IPurchaseReceipt.cs @@ -1,25 +1,25 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { - /// - /// Represents a parsed purchase receipt from a store. - /// - public interface IPurchaseReceipt - { + /// + /// Represents a parsed purchase receipt from a store. + /// + public interface IPurchaseReceipt + { /// /// The ID of the transaction. /// - string transactionID { get; } + string transactionID { get; } /// /// The ID of the product purchased. /// - string productID { get; } + string productID { get; } /// /// The date fof the purchase. /// - DateTime purchaseDate { get; } - } + DateTime purchaseDate { get; } + } } diff --git a/Runtime/SecurityStub/AppleValidator.cs b/Runtime/SecurityStub/AppleValidator.cs index 5cf651e..af955de 100644 --- a/Runtime/SecurityStub/AppleValidator.cs +++ b/Runtime/SecurityStub/AppleValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -9,18 +9,18 @@ namespace UnityEngine.Purchasing.Security /// /// This class will validate the Apple receipt is signed with the correct certificate. /// - public class AppleValidator - { + public class AppleValidator + { /// /// THIS IS A STUB, WILL NOT EXECUTE CODE! /// /// Constructs an instance with Apple Certificate. /// /// The apple certificate. - public AppleValidator (byte[] appleRootCertificate) - { - throw new NotImplementedException(); - } + public AppleValidator(byte[] appleRootCertificate) + { + throw new NotImplementedException(); + } /// /// THIS IS A STUB, WILL NOT EXECUTE CODE! @@ -30,19 +30,19 @@ public AppleValidator (byte[] appleRootCertificate) /// The Apple receipt to validate. /// The parsed AppleReceipt /// The exception thrown if the receipt is incorrectly signed. - public AppleReceipt Validate (byte [] receiptData) - { - throw new NotImplementedException(); - } - } + public AppleReceipt Validate(byte[] receiptData) + { + throw new NotImplementedException(); + } + } /// /// THIS IS A STUB, WILL NOT EXECUTE CODE! /// /// This class with parse the Apple receipt data received in byte[] into a AppleReceipt object /// - public class AppleReceiptParser - { + public class AppleReceiptParser + { /// /// THIS IS A STUB, WILL NOT EXECUTE CODE! /// @@ -50,9 +50,9 @@ public class AppleReceiptParser /// /// Apple receipt data /// The converted AppleReceipt object from the Apple receipt data - public AppleReceipt Parse (byte [] receiptData) - { - throw new NotImplementedException(); - } - } + public AppleReceipt Parse(byte[] receiptData) + { + throw new NotImplementedException(); + } + } } diff --git a/Runtime/SecurityStub/AssemblyInfo.cs b/Runtime/SecurityStub/AssemblyInfo.cs index 1ab9856..62ad114 100644 --- a/Runtime/SecurityStub/AssemblyInfo.cs +++ b/Runtime/SecurityStub/AssemblyInfo.cs @@ -1,5 +1,5 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("specs")] +[assembly: InternalsVisibleTo("specs")] diff --git a/Runtime/SecurityStub/CrossPlatformValidator.cs b/Runtime/SecurityStub/CrossPlatformValidator.cs index 98be795..716102e 100644 --- a/Runtime/SecurityStub/CrossPlatformValidator.cs +++ b/Runtime/SecurityStub/CrossPlatformValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; diff --git a/Runtime/SecurityStub/GooglePlayReceipt.cs b/Runtime/SecurityStub/GooglePlayReceipt.cs index e6ae9ed..df278b7 100644 --- a/Runtime/SecurityStub/GooglePlayReceipt.cs +++ b/Runtime/SecurityStub/GooglePlayReceipt.cs @@ -1,14 +1,14 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { - // See Google's reference docs. - // http://developer.android.com/google/play/billing/billing_reference.html + // See Google's reference docs. + // http://developer.android.com/google/play/billing/billing_reference.html /// /// The state of the GooglePlay purchase. /// - public enum GooglePurchaseState + public enum GooglePurchaseState { /// /// The purchase was completed. @@ -24,17 +24,17 @@ public enum GooglePurchaseState /// The purchase was refunded. /// Refunded - } + } /// /// A GooglePlay purchase receipt /// - public class GooglePlayReceipt : IPurchaseReceipt - { + public class GooglePlayReceipt : IPurchaseReceipt + { /// /// The item's product identifier. /// - public string productID { get; private set; } + public string productID { get; private set; } /// /// The ID of the transaction. @@ -44,27 +44,27 @@ public class GooglePlayReceipt : IPurchaseReceipt /// /// A unique order identifier for the transaction. /// - public string orderID { get; private set; } + public string orderID { get; private set; } /// /// The package name of the app. /// - public string packageName { get; private set; } + public string packageName { get; private set; } /// /// A token that uniquely identifies a purchase for a given item and user pair. /// - public string purchaseToken { get; private set; } + public string purchaseToken { get; private set; } /// /// The time the product was purchased, in milliseconds since the epoch (Jan 1, 1970). /// - public DateTime purchaseDate { get; private set; } + public DateTime purchaseDate { get; private set; } /// /// The purchase state of the order. /// - public GooglePurchaseState purchaseState { get; private set; } + public GooglePurchaseState purchaseState { get; private set; } /// /// Constructor that initializes the members from the input parameters. @@ -78,7 +78,7 @@ public class GooglePlayReceipt : IPurchaseReceipt public GooglePlayReceipt(string productID, string orderID, string packageName, string purchaseToken, DateTime purchaseTime, GooglePurchaseState purchaseState) { - throw new NotImplementedException(); - } - } + throw new NotImplementedException(); + } + } } diff --git a/Runtime/SecurityStub/Obfuscator.cs b/Runtime/SecurityStub/Obfuscator.cs index 21da9cb..24c7109 100644 --- a/Runtime/SecurityStub/Obfuscator.cs +++ b/Runtime/SecurityStub/Obfuscator.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing.Security { @@ -15,9 +15,9 @@ public static class Obfuscator /// The array of the order of the data slices used to obfuscate the data when the tangle files were originally generated. /// The encryption key to deobfuscate the tangled data at runtime, previously generated with the tangle file. /// The deobfucated public key - public static byte [] DeObfuscate (byte [] data, int [] order, int key) + public static byte[] DeObfuscate(byte[] data, int[] order, int key) { - throw new NotImplementedException (); + throw new NotImplementedException(); } } } diff --git a/Runtime/Stores/Android/AmazonAppStore/AmazonAppStoreStoreExtensions.cs b/Runtime/Stores/Android/AmazonAppStore/AmazonAppStoreStoreExtensions.cs index 3f586a9..e6ea42a 100644 --- a/Runtime/Stores/Android/AmazonAppStore/AmazonAppStoreStoreExtensions.cs +++ b/Runtime/Stores/Android/AmazonAppStore/AmazonAppStoreStoreExtensions.cs @@ -13,24 +13,25 @@ namespace UnityEngine.Purchasing /// Access Amazon store specific functionality. /// public class AmazonAppStoreStoreExtensions : IAmazonExtensions, IAmazonConfiguration - { - private AndroidJavaObject android; - /// - /// Build the AmazonAppStoreExtensions with the instance of the AmazonAppStore java object + { + private AndroidJavaObject android; + /// + /// Build the AmazonAppStoreExtensions with the instance of the AmazonAppStore java object /// - /// AmazonAppStore java object - public AmazonAppStoreStoreExtensions(AndroidJavaObject a) { - this.android = a; - } + /// AmazonAppStore java object + public AmazonAppStoreStoreExtensions(AndroidJavaObject a) + { + this.android = a; + } /// /// To use for Amazon’s local Sandbox testing app, generate a JSON description of your product catalog on the device’s SD card. /// /// Products to add to the testing app JSON. - public void WriteSandboxJSON(HashSet products) - { - android.Call("writeSandboxJSON", JSONSerializer.SerializeProductDefs(products)); - } + public void WriteSandboxJSON(HashSet products) + { + android.Call("writeSandboxJSON", JSONSerializer.SerializeProductDefs(products)); + } /// /// Amazon makes it possible to notify them of a product that cannot be fulfilled. @@ -39,18 +40,20 @@ public void WriteSandboxJSON(HashSet products) /// https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 /// /// Products transaction id - public void NotifyUnableToFulfillUnavailableProduct(string transactionID) { - android.Call("notifyUnableToFulfillUnavailableProduct", transactionID); - } + public void NotifyUnableToFulfillUnavailableProduct(string transactionID) + { + android.Call("notifyUnableToFulfillUnavailableProduct", transactionID); + } /// /// Gets the current Amazon user ID (for other Amazon services). /// public string amazonUserId - { - get { - return android.Call ("getAmazonUserId"); - } - } - } + { + get + { + return android.Call("getAmazonUserId"); + } + } + } } diff --git a/Runtime/Stores/Android/AmazonAppStore/AmazonApps.cs b/Runtime/Stores/Android/AmazonAppStore/AmazonApps.cs index bf59e66..c3ddb29 100644 --- a/Runtime/Stores/Android/AmazonAppStore/AmazonApps.cs +++ b/Runtime/Stores/Android/AmazonAppStore/AmazonApps.cs @@ -1,13 +1,13 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { - /// - /// Class for constants referencing Amazon - /// + /// + /// Class for constants referencing Amazon + /// public class AmazonApps - { + { /// /// Constant used for Mapping the store with Amazon /// - public const string Name = "AmazonApps"; - } + public const string Name = "AmazonApps"; + } } diff --git a/Runtime/Stores/Android/AmazonAppStore/FakeAmazon.cs b/Runtime/Stores/Android/AmazonAppStore/FakeAmazon.cs index a76b872..694ee94 100644 --- a/Runtime/Stores/Android/AmazonAppStore/FakeAmazon.cs +++ b/Runtime/Stores/Android/AmazonAppStore/FakeAmazon.cs @@ -9,7 +9,7 @@ namespace UnityEngine.Purchasing /// Access Amazon store specific functionality. /// public class FakeAmazonExtensions : IAmazonExtensions, IAmazonConfiguration - { + { /// /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! /// @@ -17,8 +17,8 @@ public class FakeAmazonExtensions : IAmazonExtensions, IAmazonConfiguration /// /// Products to add to the testing app JSON. public void WriteSandboxJSON(HashSet products) - { - } + { + } /// /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! @@ -29,8 +29,9 @@ public void WriteSandboxJSON(HashSet products) /// https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 /// /// Products transaction id - public void NotifyUnableToFulfillUnavailableProduct(string transactionID) { - } + public void NotifyUnableToFulfillUnavailableProduct(string transactionID) + { + } /// /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! @@ -38,8 +39,8 @@ public void NotifyUnableToFulfillUnavailableProduct(string transactionID) { /// Gets the current Amazon user ID (for other Amazon services). /// public string amazonUserId - { - get { return "fakeid"; } - } - } + { + get { return "fakeid"; } + } + } } diff --git a/Runtime/Stores/Android/AmazonAppStore/IAmazonConfiguration.cs b/Runtime/Stores/Android/AmazonAppStore/IAmazonConfiguration.cs index 45ef6ac..12075d4 100644 --- a/Runtime/Stores/Android/AmazonAppStore/IAmazonConfiguration.cs +++ b/Runtime/Stores/Android/AmazonAppStore/IAmazonConfiguration.cs @@ -1,18 +1,18 @@ -using System.Collections.Generic; +using System.Collections.Generic; using UnityEngine.Purchasing; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { - /// - /// Access Amazon store specific configurations. - /// + /// + /// Access Amazon store specific configurations. + /// public interface IAmazonConfiguration : IStoreConfiguration - { - /// - /// To use for Amazon’s local Sandbox testing app, generate a JSON description of your product catalog on the device’s SD card. + { + /// + /// To use for Amazon’s local Sandbox testing app, generate a JSON description of your product catalog on the device’s SD card. /// - /// Products to add to the testing app JSON. + /// Products to add to the testing app JSON. void WriteSandboxJSON(HashSet products); - } + } } diff --git a/Runtime/Stores/Android/AmazonAppStore/IAmazonExtensions.cs b/Runtime/Stores/Android/AmazonAppStore/IAmazonExtensions.cs index 5cc742b..2a1832f 100644 --- a/Runtime/Stores/Android/AmazonAppStore/IAmazonExtensions.cs +++ b/Runtime/Stores/Android/AmazonAppStore/IAmazonExtensions.cs @@ -7,19 +7,19 @@ namespace UnityEngine.Purchasing /// Access Amazon store specific functionality. /// public interface IAmazonExtensions : IStoreExtension - { + { /// /// Gets the current Amazon user ID (for other Amazon services). /// - string amazonUserId { get; } + string amazonUserId { get; } - /// - /// Amazon makes it possible to notify them of a product that cannot be fulfilled. - /// - /// This method calls Amazon's notifyFulfillment(transactionID, FulfillmentResult.UNAVAILABLE); - /// https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 - /// - /// Products transaction id + /// + /// Amazon makes it possible to notify them of a product that cannot be fulfilled. + /// + /// This method calls Amazon's notifyFulfillment(transactionID, FulfillmentResult.UNAVAILABLE); + /// https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 + /// + /// Products transaction id void NotifyUnableToFulfillUnavailableProduct(string transactionID); - } + } } diff --git a/Runtime/Stores/Android/AndroidJavaStore.cs b/Runtime/Stores/Android/AndroidJavaStore.cs index 6b3450d..2d4bd70 100644 --- a/Runtime/Stores/Android/AndroidJavaStore.cs +++ b/Runtime/Stores/Android/AndroidJavaStore.cs @@ -1,33 +1,34 @@ -using System; +using System; using UnityEngine; namespace UnityEngine.Purchasing { - internal class AndroidJavaStore : INativeStore - { - private AndroidJavaObject m_Store; - protected AndroidJavaObject GetStore() - { - return m_Store; - } + internal class AndroidJavaStore : INativeStore + { + private AndroidJavaObject m_Store; + protected AndroidJavaObject GetStore() + { + return m_Store; + } - public AndroidJavaStore(AndroidJavaObject store) { - this.m_Store = store; - } + public AndroidJavaStore(AndroidJavaObject store) + { + this.m_Store = store; + } - public void RetrieveProducts (string json) - { - m_Store.Call ("RetrieveProducts", json); - } + public void RetrieveProducts(string json) + { + m_Store.Call("RetrieveProducts", json); + } - public virtual void Purchase (string productJSON, string developerPayload) - { - m_Store.Call ("Purchase", productJSON, developerPayload); - } + public virtual void Purchase(string productJSON, string developerPayload) + { + m_Store.Call("Purchase", productJSON, developerPayload); + } - public virtual void FinishTransaction (string productJSON, string transactionID) - { - m_Store.Call ("FinishTransaction", productJSON, transactionID); - } - } + public virtual void FinishTransaction(string productJSON, string transactionID) + { + m_Store.Call("FinishTransaction", productJSON, transactionID); + } + } } diff --git a/Runtime/Stores/Android/AndroidStore.cs b/Runtime/Stores/Android/AndroidStore.cs index b7a8c60..a410d32 100644 --- a/Runtime/Stores/Android/AndroidStore.cs +++ b/Runtime/Stores/Android/AndroidStore.cs @@ -1,4 +1,4 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// The type of Android store being run. diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleCachedQuerySkuDetailsService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleCachedQuerySkuDetailsService.cs index 99c7d78..95e942f 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleCachedQuerySkuDetailsService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GoogleCachedQuerySkuDetailsService.cs @@ -3,7 +3,7 @@ namespace UnityEngine.Purchasing { - class GoogleCachedQuerySkuDetailsService: IGoogleCachedQuerySkuDetailsService + class GoogleCachedQuerySkuDetailsService : IGoogleCachedQuerySkuDetailsService { Dictionary m_CachedQueriedSkus = new Dictionary(); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs index 7dbbd87..9b64281 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs @@ -2,7 +2,7 @@ namespace UnityEngine.Purchasing { - class GoogleLastKnownProductService: IGoogleLastKnownProductService + class GoogleLastKnownProductService : IGoogleLastKnownProductService { string m_LastKnownProductId = null; GooglePlayProrationMode? m_LastKnownProrationMode = GooglePlayProrationMode.UnknownSubscriptionUpgradeDowngradePolicy; diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs index 827f3eb..9472772 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs @@ -95,32 +95,32 @@ protected virtual void DequeueQueryProducts() switch (m_GoogleConnectionState) { case GoogleBillingConnectionState.Connected: - { - var productDescriptionQuery = m_ProductsToQuery.Dequeue(); - m_QuerySkuDetailsService.QueryAsyncSku(productDescriptionQuery.products, productDescriptionQuery.onProductsReceived); - break; - } + { + var productDescriptionQuery = m_ProductsToQuery.Dequeue(); + m_QuerySkuDetailsService.QueryAsyncSku(productDescriptionQuery.products, productDescriptionQuery.onProductsReceived); + break; + } case GoogleBillingConnectionState.Disconnected: - { - var productDescriptionQuery = m_ProductsToQuery.Dequeue(); - var reason = AreConnectionAttemptsExhausted() ? GoogleRetrieveProductsFailureReason.BillingServiceUnavailable : GoogleRetrieveProductsFailureReason.BillingServiceDisconnected; - productDescriptionQuery.onRetrieveProductsFailed(reason); - - productsFailedToDequeue.Enqueue(productDescriptionQuery); - break; - } + { + var productDescriptionQuery = m_ProductsToQuery.Dequeue(); + var reason = AreConnectionAttemptsExhausted() ? GoogleRetrieveProductsFailureReason.BillingServiceUnavailable : GoogleRetrieveProductsFailureReason.BillingServiceDisconnected; + productDescriptionQuery.onRetrieveProductsFailed(reason); + + productsFailedToDequeue.Enqueue(productDescriptionQuery); + break; + } case GoogleBillingConnectionState.Connecting: - { - stop = true; - break; - } + { + stop = true; + break; + } default: - { - Debug.LogErrorFormat("GooglePlayStoreService state ({0}) unrecognized, cannot process ProductDescriptionQuery", - m_GoogleConnectionState); - stop = true; - break; - } + { + Debug.LogErrorFormat("GooglePlayStoreService state ({0}) unrecognized, cannot process ProductDescriptionQuery", + m_GoogleConnectionState); + stop = true; + break; + } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingResult.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingResult.cs index fad84dd..c12547f 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingResult.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingResult.cs @@ -2,10 +2,12 @@ namespace UnityEngine.Purchasing.Models { interface IGoogleBillingResult { - GoogleBillingResponseCode responseCode { + GoogleBillingResponseCode responseCode + { get; } - string debugMessage { + string debugMessage + { get; } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs index a30dcd3..4f0bb62 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs @@ -136,7 +136,7 @@ void HandleUserCancelledPurchaseFailure(IGoogleBillingResult billingResult, IEnumerable googlePurchases) { var googlePurchase = googlePurchases.FirstOrDefault(purchase => - purchase.sku == m_LastKnownProductService.GetLastKnownProductId()); + purchase?.sku == m_LastKnownProductService.GetLastKnownProductId()); if (googlePurchase != null && !googlePurchase.IsAcknowledged()) { diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/SkuDetailsResponseListener.cs b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/SkuDetailsResponseListener.cs index 7c43711..4cdb655 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/SkuDetailsResponseListener.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/SkuDetailsResponseListener.cs @@ -11,7 +11,7 @@ namespace UnityEngine.Purchasing /// This is C# representation of the Java Class SkuDetailsResponseListener /// See more /// - class SkuDetailsResponseListener: AndroidJavaProxy + class SkuDetailsResponseListener : AndroidJavaProxy { const string k_AndroidSkuDetailsResponseListenerClassName = "com.android.billingclient.api.SkuDetailsResponseListener"; diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs index 091d17a..90ee50d 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs @@ -97,7 +97,7 @@ public AndroidJavaObject QueryPurchase(string skuType) } public void QuerySkuDetailsAsync(List skus, string type, - Action> onSkuDetailsResponseAction) + Action> onSkuDetailsResponseAction) { var skuDetailsParamsBuilder = GetSkuDetailsParamClass().CallStatic("newBuilder"); skuDetailsParamsBuilder = skuDetailsParamsBuilder.Call("setSkusList", skus.ToJava()); @@ -128,7 +128,7 @@ AndroidJavaObject MakeBillingFlowParams(AndroidJavaObject sku, string oldSku, st if (prorationMode != null) { - billingFlowParams = billingFlowParams.Call("setReplaceSkusProrationMode", (int) prorationMode); + billingFlowParams = billingFlowParams.Call("setReplaceSkusProrationMode", (int)prorationMode); } billingFlowParams = billingFlowParams.Call("build"); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingResult.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingResult.cs index da69a97..0cd0b8a 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingResult.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingResult.cs @@ -14,7 +14,7 @@ internal GoogleBillingResult(AndroidJavaObject billingResult) { if (billingResult != null) { - responseCode = (GoogleBillingResponseCode) billingResult.Call("getResponseCode"); + responseCode = (GoogleBillingResponseCode)billingResult.Call("getResponseCode"); debugMessage = billingResult.Call("getDebugMessage"); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs index 9e141fc..da2eb41 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs @@ -15,7 +15,7 @@ class GooglePurchaseResult internal GooglePurchaseResult(AndroidJavaObject purchaseResult, IGoogleCachedQuerySkuDetailsService cachedQuerySkuDetailsService) { - m_ResponseCode = (GoogleBillingResponseCode) purchaseResult.Call("getResponseCode"); + m_ResponseCode = (GoogleBillingResponseCode)purchaseResult.Call("getResponseCode"); FillPurchases(purchaseResult, cachedQuerySkuDetailsService); } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/AndroidJavaObjectWrapper.cs b/Runtime/Stores/Android/GooglePlay/AAR/Utils/AndroidJavaObjectWrapper.cs index 026032a..3dc2c61 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Utils/AndroidJavaObjectWrapper.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Utils/AndroidJavaObjectWrapper.cs @@ -13,7 +13,7 @@ public AndroidJavaObjectWrapper(AndroidJavaObject obj) public ReturnType Call(string methodName, params object[] args) { - var obj = (AndroidJavaObject) androidJavaObject; + var obj = (AndroidJavaObject)androidJavaObject; return obj.Call(methodName, args); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs index 6899051..4113e4c 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs @@ -9,7 +9,8 @@ internal static string EncodeReceipt(string transactionId, string purchaseOrigin return FormatPayload(purchaseOriginalJson, purchaseSignature, skuDetailsJson); } - static string FormatPayload(string json, string signature, string skuDetails) { + static string FormatPayload(string json, string signature, string skuDetails) + { var dic = new Dictionary { ["json"] = json, diff --git a/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreExtensions.cs index 801dfae..9ffd0e5 100644 --- a/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreExtensions.cs @@ -8,7 +8,7 @@ namespace UnityEngine.Purchasing /// /// Access GooglePlay store specific functionality. /// - public class FakeGooglePlayStoreExtensions: IGooglePlayStoreExtensions + public class FakeGooglePlayStoreExtensions : IGooglePlayStoreExtensions { /// /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlay.cs b/Runtime/Stores/Android/GooglePlay/GooglePlay.cs index 98b9987..281177f 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlay.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlay.cs @@ -1,13 +1,13 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// Class for constants referencing GooglePlay /// - public class GooglePlay - { + public class GooglePlay + { /// /// Constant used for Mapping the store with GooglePlay /// - public const string Name = "GooglePlay"; - } + public const string Name = "GooglePlay"; + } } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs index a258f19..f2a8836 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs @@ -7,7 +7,7 @@ namespace UnityEngine.Purchasing /// /// Access Google Play store specific configurations. /// - class GooglePlayConfiguration: IGooglePlayConfiguration, IGooglePlayConfigurationInternal + class GooglePlayConfiguration : IGooglePlayConfiguration, IGooglePlayConfigurationInternal { Action m_InitializationConnectionLister; IGooglePlayStoreService m_GooglePlayStoreService; diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs index 0d486ee..959466e 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs @@ -3,7 +3,7 @@ namespace UnityEngine.Purchasing { - class GooglePlayPurchaseCallback: IGooglePurchaseCallback + class GooglePlayPurchaseCallback : IGooglePurchaseCallback { IStoreCallback m_StoreCallback; IGooglePlayConfigurationInternal m_GooglePlayConfigurationInternal; diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs index c09c3e7..1590f79 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs @@ -6,7 +6,7 @@ namespace UnityEngine.Purchasing { - class GooglePlayStore: AbstractStore + class GooglePlayStore : AbstractStore { IGooglePlayStoreRetrieveProductsService m_RetrieveProductsService; IGooglePlayStorePurchaseService m_StorePurchaseService; diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs index 0bbd3a9..deafa8e 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs @@ -7,7 +7,7 @@ namespace UnityEngine.Purchasing { - class GooglePlayStoreExtensions: IGooglePlayStoreExtensions, IGooglePlayStoreExtensionsInternal + class GooglePlayStoreExtensions : IGooglePlayStoreExtensions, IGooglePlayStoreExtensionsInternal { IGooglePlayStoreService m_GooglePlayStoreService; IGooglePlayStoreFinishTransactionService m_GooglePlayStoreFinishTransactionService; @@ -30,7 +30,7 @@ public void UpgradeDowngradeSubscription(string oldSku, string newSku) public void UpgradeDowngradeSubscription(string oldSku, string newSku, int desiredProrationMode) { - UpgradeDowngradeSubscription(oldSku, newSku, (GooglePlayProrationMode) desiredProrationMode); + UpgradeDowngradeSubscription(oldSku, newSku, (GooglePlayProrationMode)desiredProrationMode); } public virtual void UpgradeDowngradeSubscription(string oldSku, string newSku, GooglePlayProrationMode desiredProrationMode) diff --git a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs index d4e9912..0a3d87e 100644 --- a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs @@ -6,7 +6,7 @@ namespace UnityEngine.Purchasing /// /// Access Google Play store specific configurations. /// - public interface IGooglePlayConfiguration: IStoreConfiguration + public interface IGooglePlayConfiguration : IStoreConfiguration { /// /// Set an optional listener for failures when connecting to the base Google Play Billing service. This may be called diff --git a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayStoreExtensions.cs index 0863c46..6e0f303 100644 --- a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayStoreExtensions.cs @@ -5,10 +5,10 @@ namespace UnityEngine.Purchasing { - /// - /// Access GooglePlay store specific functionality. - /// - public interface IGooglePlayStoreExtensions : IStoreExtension + /// + /// Access GooglePlay store specific functionality. + /// + public interface IGooglePlayStoreExtensions : IStoreExtension { /// /// Upgrade or downgrade subscriptions, with proration mode `IMMEDIATE_WITHOUT_PRORATION` by default diff --git a/Runtime/Stores/Android/GooglePlay/package.json b/Runtime/Stores/Android/GooglePlay/package.json new file mode 100644 index 0000000..ebe4eb6 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/package.json @@ -0,0 +1,7 @@ +{ + "name": "GooglePlay", + "version": "1.0.0", + "dependencies": { + + } +} diff --git a/Runtime/Stores/Android/GooglePlay/package.json.meta b/Runtime/Stores/Android/GooglePlay/package.json.meta new file mode 100644 index 0000000..45d7320 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: aee966c2a3479f841ae8ef29d0cfa262 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Stores/Android/IAndroidStoreSelection.cs b/Runtime/Stores/Android/IAndroidStoreSelection.cs index 3394ec2..b026690 100644 --- a/Runtime/Stores/Android/IAndroidStoreSelection.cs +++ b/Runtime/Stores/Android/IAndroidStoreSelection.cs @@ -1,16 +1,16 @@ -using System; +using System; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { - /// - /// Store configuration for Android stores. - /// + /// + /// Store configuration for Android stores. + /// public interface IAndroidStoreSelection : IStoreConfiguration - { + { /// - /// A property that retrieves the AppStore type. - /// + /// A property that retrieves the AppStore type. + /// AppStore appStore { get; } - } + } } diff --git a/Runtime/Stores/Android/IUnityCallback.cs b/Runtime/Stores/Android/IUnityCallback.cs index d2c7ff5..8ffa0bd 100644 --- a/Runtime/Stores/Android/IUnityCallback.cs +++ b/Runtime/Stores/Android/IUnityCallback.cs @@ -1,14 +1,15 @@ -using System; +using System; namespace UnityEngine.Purchasing { - /// - /// JSON based Native callback interface. - /// - internal interface IUnityCallback { - void OnSetupFailed(String json); - void OnProductsRetrieved(String json); - void OnPurchaseSucceeded(String id, String receipt, String transactionID); - void OnPurchaseFailed(String json); - } + /// + /// JSON based Native callback interface. + /// + internal interface IUnityCallback + { + void OnSetupFailed(String json); + void OnProductsRetrieved(String json); + void OnPurchaseSucceeded(String id, String receipt, String transactionID); + void OnPurchaseFailed(String json); + } } diff --git a/Runtime/Stores/Android/JSONSerializer.cs b/Runtime/Stores/Android/JSONSerializer.cs index 47b8550..df7a8d4 100644 --- a/Runtime/Stores/Android/JSONSerializer.cs +++ b/Runtime/Stores/Android/JSONSerializer.cs @@ -1,21 +1,24 @@ -using System; +using System; using UnityEngine.Purchasing.Extension; using System.Collections.Generic; using System.Reflection; namespace UnityEngine.Purchasing { - static class SerializationExtensions { - public static string TryGetString(this Dictionary dic, string key) - { - if (dic.ContainsKey (key)) { - if (dic [key] != null) { - return dic [key].ToString (); - } - } - return null; - } - } + static class SerializationExtensions + { + public static string TryGetString(this Dictionary dic, string key) + { + if (dic.ContainsKey(key)) + { + if (dic[key] != null) + { + return dic[key].ToString(); + } + } + return null; + } + } internal class JSONSerializer { @@ -51,13 +54,13 @@ public static string SerializeProductDescs(IEnumerable produ public static List DeserializeProductDescriptions(string json) { - var objects = (List) MiniJson.JsonDecode(json); + var objects = (List)MiniJson.JsonDecode(json); var result = new List(); foreach (Dictionary obj in objects) { - var metadata = DeserializeMetadata((Dictionary) obj["metadata"]); + var metadata = DeserializeMetadata((Dictionary)obj["metadata"]); var product = new ProductDescription( - (string) obj["storeSpecificId"], + (string)obj["storeSpecificId"], metadata, obj.TryGetString("receipt"), obj.TryGetString("transactionId"), @@ -69,14 +72,14 @@ public static List DeserializeProductDescriptions(string jso public static Dictionary DeserializeSubscriptionDescriptions(string json) { - var objects = (List) MiniJson.JsonDecode(json); + var objects = (List)MiniJson.JsonDecode(json); var result = new Dictionary(); foreach (Dictionary obj in objects) { var subscription = new Dictionary(); if (obj.TryGetValue("metadata", out var metadata)) { - var metadataDict = (Dictionary) metadata; + var metadataDict = (Dictionary)metadata; subscription["introductoryPrice"] = metadataDict.TryGetString("introductoryPrice"); subscription["introductoryPriceLocale"] = metadataDict.TryGetString("introductoryPriceLocale"); subscription["introductoryPriceNumberOfPeriods"] = metadataDict.TryGetString("introductoryPriceNumberOfPeriods"); @@ -84,7 +87,8 @@ public static Dictionary DeserializeSubscriptionDescriptions(str subscription["unit"] = metadataDict.TryGetString("unit"); // this is a double check for Apple side's bug - if (!string.IsNullOrEmpty(subscription["numberOfUnits"]) && string.IsNullOrEmpty(subscription["unit"])) { + if (!string.IsNullOrEmpty(subscription["numberOfUnits"]) && string.IsNullOrEmpty(subscription["unit"])) + { subscription["unit"] = "0"; } } @@ -95,7 +99,7 @@ public static Dictionary DeserializeSubscriptionDescriptions(str if (obj.TryGetValue("storeSpecificId", out var id)) { - var idStr = (string) id; + var idStr = (string)id; result.Add(idStr, MiniJson.JsonEncode(subscription)); } else @@ -109,7 +113,7 @@ public static Dictionary DeserializeSubscriptionDescriptions(str public static Dictionary DeserializeProductDetails(string json) { - var objects = (List) MiniJson.JsonDecode(json); + var objects = (List)MiniJson.JsonDecode(json); var result = new Dictionary(); foreach (Dictionary obj in objects) { @@ -117,7 +121,7 @@ public static Dictionary DeserializeProductDetails(string json) var details = new Dictionary(); if (obj.TryGetValue("metadata", out var metadata)) { - var metadataStr = (Dictionary) metadata; + var metadataStr = (Dictionary)metadata; details["subscriptionNumberOfUnits"] = metadataStr.TryGetString("subscriptionNumberOfUnits"); details["subscriptionPeriodUnit"] = metadataStr.TryGetString("subscriptionPeriodUnit"); details["localizedPrice"] = metadataStr.TryGetString("localizedPrice"); @@ -132,12 +136,14 @@ public static Dictionary DeserializeProductDetails(string json) details["unit"] = metadataStr.TryGetString("unit"); // this is a double check for Apple side's bug - if (!string.IsNullOrEmpty(details["subscriptionNumberOfUnits"]) && string.IsNullOrEmpty(details["subscriptionPeriodUnit"])) { + if (!string.IsNullOrEmpty(details["subscriptionNumberOfUnits"]) && string.IsNullOrEmpty(details["subscriptionPeriodUnit"])) + { details["subscriptionPeriodUnit"] = "0"; } // this is a double check for Apple side's bug - if (!string.IsNullOrEmpty(details["numberOfUnits"]) && string.IsNullOrEmpty(details["unit"])) { + if (!string.IsNullOrEmpty(details["numberOfUnits"]) && string.IsNullOrEmpty(details["unit"])) + { details["unit"] = "0"; } } @@ -148,7 +154,7 @@ public static Dictionary DeserializeProductDetails(string json) if (obj.TryGetValue("storeSpecificId", out var id)) { - var idStr = (string) id; + var idStr = (string)id; result.Add(idStr, MiniJson.JsonEncode(details)); } else @@ -162,19 +168,19 @@ public static Dictionary DeserializeProductDetails(string json) public static PurchaseFailureDescription DeserializeFailureReason(string json) { - var dic = (Dictionary) MiniJson.JsonDecode(json); + var dic = (Dictionary)MiniJson.JsonDecode(json); var reason = PurchaseFailureReason.Unknown; if (dic.TryGetValue("reason", out var reasonStr)) { - if (Enum.IsDefined(typeof(PurchaseFailureReason), (string) reasonStr)) + if (Enum.IsDefined(typeof(PurchaseFailureReason), (string)reasonStr)) { - reason = (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), (string) reasonStr); + reason = (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), (string)reasonStr); } if (dic.TryGetValue("productId", out var productId)) { - return new PurchaseFailureDescription( (string) productId, reason, dic.TryGetString("message")); + return new PurchaseFailureDescription((string)productId, reason, dic.TryGetString("message")); } } else @@ -182,7 +188,7 @@ public static PurchaseFailureDescription DeserializeFailureReason(string json) Debug.LogWarning("Reason key not found in purchase failure json: " + json); } - return new PurchaseFailureDescription( "Unknown ProductID", reason, dic.TryGetString("message")); + return new PurchaseFailureDescription("Unknown ProductID", reason, dic.TryGetString("message")); } private static ProductMetadata DeserializeMetadata(Dictionary data) @@ -194,9 +200,12 @@ private static ProductMetadata DeserializeMetadata(Dictionary da // an exception. The best solution is to pass a number for localizedPrice when possible, to avoid any string // parsing issues. decimal localizedPrice = 0.0m; - try { + try + { localizedPrice = Convert.ToDecimal(data["localizedPrice"]); - } catch { + } + catch + { localizedPrice = 0.0m; } @@ -217,10 +226,14 @@ private static Dictionary EncodeProductDef(ProductDefinition pro bool enabled = true; var enabledProp = typeof(ProductDefinition).GetProperty("enabled"); - if (enabledProp != null) { - try { + if (enabledProp != null) + { + try + { enabled = Convert.ToBoolean(enabledProp.GetValue(product, null)); - } catch { + } + catch + { enabled = true; } } @@ -228,11 +241,14 @@ private static Dictionary EncodeProductDef(ProductDefinition pro var payoutsArray = new List(); var payoutsProp = typeof(ProductDefinition).GetProperty("payouts"); - if (payoutsProp != null) { + if (payoutsProp != null) + { var payoutsObject = payoutsProp.GetValue(product, null); Array payouts = payoutsObject as Array; - if (payouts != null) { - foreach (object payout in payouts) { + if (payouts != null) + { + foreach (object payout in payouts) + { var payoutDict = new Dictionary(); var payoutType = payout.GetType(); payoutDict["t"] = payoutType.GetField("typeString").GetValue(payout); @@ -250,7 +266,7 @@ private static Dictionary EncodeProductDef(ProductDefinition pro private static Dictionary EncodeProductDesc(ProductDescription product) { - var prod = new Dictionary {{"storeSpecificId", product.storeSpecificId}}; + var prod = new Dictionary { { "storeSpecificId", product.storeSpecificId } }; // ProductDescription.type field available in Unity 5.4+. Access by reflection here. Type pdClassType = typeof(ProductDescription); diff --git a/Runtime/Stores/Android/JavaBridge.cs b/Runtime/Stores/Android/JavaBridge.cs index 64df4ec..906b0e6 100644 --- a/Runtime/Stores/Android/JavaBridge.cs +++ b/Runtime/Stores/Android/JavaBridge.cs @@ -1,38 +1,42 @@ -using System; +using System; using UnityEngine; namespace UnityEngine.Purchasing { - /// - /// Receives callbacks from Android based stores. - /// - internal class JavaBridge : AndroidJavaProxy, IUnityCallback - { - private IUnityCallback forwardTo; - public JavaBridge (IUnityCallback forwardTo) : base("com.unity.purchasing.common.IUnityCallback") - { - this.forwardTo = forwardTo; - } + /// + /// Receives callbacks from Android based stores. + /// + internal class JavaBridge : AndroidJavaProxy, IUnityCallback + { + private IUnityCallback forwardTo; + public JavaBridge(IUnityCallback forwardTo) : base("com.unity.purchasing.common.IUnityCallback") + { + this.forwardTo = forwardTo; + } - public JavaBridge (IUnityCallback forwardTo, string javaInterface) : base(javaInterface) - { - this.forwardTo = forwardTo; - } + public JavaBridge(IUnityCallback forwardTo, string javaInterface) : base(javaInterface) + { + this.forwardTo = forwardTo; + } - public void OnSetupFailed(String json) { - forwardTo.OnSetupFailed (json); - } + public void OnSetupFailed(String json) + { + forwardTo.OnSetupFailed(json); + } - public void OnProductsRetrieved(String json) { - forwardTo.OnProductsRetrieved (json); - } + public void OnProductsRetrieved(String json) + { + forwardTo.OnProductsRetrieved(json); + } - public void OnPurchaseSucceeded(String id, String receipt, String transactionID) { - forwardTo.OnPurchaseSucceeded (id, receipt, transactionID); - } + public void OnPurchaseSucceeded(String id, String receipt, String transactionID) + { + forwardTo.OnPurchaseSucceeded(id, receipt, transactionID); + } - public void OnPurchaseFailed(String json) { - forwardTo.OnPurchaseFailed (json); - } - } + public void OnPurchaseFailed(String json) + { + forwardTo.OnPurchaseFailed(json); + } + } } diff --git a/Runtime/Stores/Android/ScriptingStoreCallback.cs b/Runtime/Stores/Android/ScriptingStoreCallback.cs index 7767f07..46109f5 100644 --- a/Runtime/Stores/Android/ScriptingStoreCallback.cs +++ b/Runtime/Stores/Android/ScriptingStoreCallback.cs @@ -1,23 +1,24 @@ -using System; +using System; using System.Collections.Generic; using Uniject; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { - /// - /// Wraps an IStoreCallback executing methods on - /// the scripting thread. - /// - internal class ScriptingStoreCallback : IStoreCallback - { + /// + /// Wraps an IStoreCallback executing methods on + /// the scripting thread. + /// + internal class ScriptingStoreCallback : IStoreCallback + { IStoreCallback m_ForwardTo; IUtil m_Util; - public ScriptingStoreCallback(IStoreCallback forwardTo, IUtil util) { - m_ForwardTo = forwardTo; - m_Util = util; - } + public ScriptingStoreCallback(IStoreCallback forwardTo, IUtil util) + { + m_ForwardTo = forwardTo; + m_Util = util; + } public ProductCollection products => m_ForwardTo.products; @@ -31,10 +32,10 @@ public void OnProductsRetrieved(List products) m_Util.RunOnMainThread(() => m_ForwardTo.OnProductsRetrieved(products)); } - public void OnPurchaseSucceeded (string id, string receipt, string transactionID) - { - m_Util.RunOnMainThread (() => m_ForwardTo.OnPurchaseSucceeded (id, receipt, transactionID)); - } + public void OnPurchaseSucceeded(string id, string receipt, string transactionID) + { + m_Util.RunOnMainThread(() => m_ForwardTo.OnPurchaseSucceeded(id, receipt, transactionID)); + } public void OnAllPurchasesRetrieved(List purchasedProducts) { @@ -49,4 +50,3 @@ public void OnPurchaseFailed(PurchaseFailureDescription desc) public bool useTransactionLog { get; set; } } } - diff --git a/Runtime/Stores/Android/ScriptingUnityCallback.cs b/Runtime/Stores/Android/ScriptingUnityCallback.cs index 1d48cba..11be39d 100644 --- a/Runtime/Stores/Android/ScriptingUnityCallback.cs +++ b/Runtime/Stores/Android/ScriptingUnityCallback.cs @@ -1,41 +1,41 @@ -using System; +using System; using Uniject; namespace UnityEngine.Purchasing { - /// - /// Wraps an IUnityCallback executing methods on - /// the scripting thread. - /// - internal class ScriptingUnityCallback : IUnityCallback - { - private IUnityCallback forwardTo; - private IUtil util; + /// + /// Wraps an IUnityCallback executing methods on + /// the scripting thread. + /// + internal class ScriptingUnityCallback : IUnityCallback + { + private IUnityCallback forwardTo; + private IUtil util; - public ScriptingUnityCallback(IUnityCallback forwardTo, IUtil util) { - this.forwardTo = forwardTo; - this.util = util; - } + public ScriptingUnityCallback(IUnityCallback forwardTo, IUtil util) + { + this.forwardTo = forwardTo; + this.util = util; + } - public void OnSetupFailed (string json) - { - util.RunOnMainThread(() => forwardTo.OnSetupFailed(json)); - } + public void OnSetupFailed(string json) + { + util.RunOnMainThread(() => forwardTo.OnSetupFailed(json)); + } - public void OnProductsRetrieved (string json) - { - util.RunOnMainThread (() => forwardTo.OnProductsRetrieved (json)); - } + public void OnProductsRetrieved(string json) + { + util.RunOnMainThread(() => forwardTo.OnProductsRetrieved(json)); + } - public void OnPurchaseSucceeded (string id, string receipt, string transactionID) - { - util.RunOnMainThread (() => forwardTo.OnPurchaseSucceeded (id, receipt, transactionID)); - } + public void OnPurchaseSucceeded(string id, string receipt, string transactionID) + { + util.RunOnMainThread(() => forwardTo.OnPurchaseSucceeded(id, receipt, transactionID)); + } - public void OnPurchaseFailed (string json) - { - util.RunOnMainThread (() => forwardTo.OnPurchaseFailed (json)); - } - } + public void OnPurchaseFailed(string json) + { + util.RunOnMainThread(() => forwardTo.OnPurchaseFailed(json)); + } + } } - diff --git a/Runtime/Stores/Android/UDP/FakeUDPExtension.cs b/Runtime/Stores/Android/UDP/FakeUDPExtension.cs index d176ec0..1c0c706 100644 --- a/Runtime/Stores/Android/UDP/FakeUDPExtension.cs +++ b/Runtime/Stores/Android/UDP/FakeUDPExtension.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing { diff --git a/Runtime/Stores/Android/UDP/INativeUDPStore.cs b/Runtime/Stores/Android/UDP/INativeUDPStore.cs index 709f189..ab41fa1 100644 --- a/Runtime/Stores/Android/UDP/INativeUDPStore.cs +++ b/Runtime/Stores/Android/UDP/INativeUDPStore.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; namespace UnityEngine.Purchasing diff --git a/Runtime/Stores/Android/UDP/IUDPExtensions.cs b/Runtime/Stores/Android/UDP/IUDPExtensions.cs index 5e7b1a8..f4aa54f 100644 --- a/Runtime/Stores/Android/UDP/IUDPExtensions.cs +++ b/Runtime/Stores/Android/UDP/IUDPExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing { diff --git a/Runtime/Stores/Android/UDP/UDP.cs b/Runtime/Stores/Android/UDP/UDP.cs index 93eb6af..6e20279 100644 --- a/Runtime/Stores/Android/UDP/UDP.cs +++ b/Runtime/Stores/Android/UDP/UDP.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing { diff --git a/Runtime/Stores/Android/UDP/UDPBindings.cs b/Runtime/Stores/Android/UDP/UDPBindings.cs index bb2561b..8d21ea5 100644 --- a/Runtime/Stores/Android/UDP/UDPBindings.cs +++ b/Runtime/Stores/Android/UDP/UDPBindings.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -32,7 +32,7 @@ public void Initialize(Action callback) if (m_Bridge != null) { var initMethod = UdpIapBridgeInterface.GetInitMethod(); - initMethod.Invoke(m_Bridge, new object[] {callback}); + initMethod.Invoke(m_Bridge, new object[] { callback }); } else { @@ -46,7 +46,7 @@ public void Purchase(string productId, Action callback, string dev if (m_Bridge != null) { var purchaseMethod = UdpIapBridgeInterface.GetPurchaseMethod(); - purchaseMethod.Invoke(m_Bridge, new object[] {productId, callback, developerPayload}); + purchaseMethod.Invoke(m_Bridge, new object[] { productId, callback, developerPayload }); } else { @@ -75,7 +75,7 @@ public void RetrieveProducts(ReadOnlyCollection products, Act { ids.Add(product.storeSpecificId); } - retrieveProductsMethod.Invoke(m_Bridge, new object[] {new ReadOnlyCollection(ids), retrieveCallback}); + retrieveProductsMethod.Invoke(m_Bridge, new object[] { new ReadOnlyCollection(ids), retrieveCallback }); } else { @@ -89,7 +89,7 @@ public void FinishTransaction(ProductDefinition productDefinition, string transa if (m_Bridge != null) { var finishTransactionMethod = UdpIapBridgeInterface.GetFinishTransactionMethod(); - finishTransactionMethod.Invoke(m_Bridge, new object[] {transactionID}); + finishTransactionMethod.Invoke(m_Bridge, new object[] { transactionID }); } else { @@ -114,7 +114,7 @@ private void OnInventoryQueried(bool success, object payload) HashSet fetchedProducts = new HashSet(); var getProductList = InventoryInterface.GetProductListMethod(); - var products = (IEnumerable) getProductList.Invoke(inventory, null); + var products = (IEnumerable)getProductList.Invoke(inventory, null); var productList = products.Cast().ToList(); foreach (var productInfo in productList) @@ -126,23 +126,23 @@ private void OnInventoryQueried(bool success, object payload) var microsProp = ProductInfoInterface.GetPriceAmountMicrosProp(); ProductMetadata metadata = new ProductMetadata( - (string) priceProp.GetValue(productInfo, null), - (string) titleProp.GetValue(productInfo, null), - (string) descProp.GetValue(productInfo, null), - (string) currencyProp.GetValue(productInfo, null), - Convert.ToDecimal((long) microsProp.GetValue(productInfo, null)) / 1000000); + (string)priceProp.GetValue(productInfo, null), + (string)titleProp.GetValue(productInfo, null), + (string)descProp.GetValue(productInfo, null), + (string)currencyProp.GetValue(productInfo, null), + Convert.ToDecimal((long)microsProp.GetValue(productInfo, null)) / 1000000); var idProp = ProductInfoInterface.GetProductIdProp(); - var productId = (string) idProp.GetValue(productInfo, null); + var productId = (string)idProp.GetValue(productInfo, null); ProductDescription desc = new ProductDescription(productId, metadata); var hasPurchase = InventoryInterface.HasPurchaseMethod(); - if ((bool) hasPurchase.Invoke(inventory, new object[] {productId})) + if ((bool)hasPurchase.Invoke(inventory, new object[] { productId })) { var getPurchaseInfo = InventoryInterface.GetPurchaseInfoMethod(); - object purchase = getPurchaseInfo.Invoke(inventory, new object[] {productId}); + object purchase = getPurchaseInfo.Invoke(inventory, new object[] { productId }); var dic = StringPropertyToDictionary(purchase); string transactionId = dic["GameOrderId"]; @@ -180,7 +180,7 @@ private void OnInventoryQueried(bool success, object payload) } else { - parsedPayload = (string) payload; + parsedPayload = (string)payload; } m_RetrieveProductsCallbackCache(actualSuccess, parsedPayload); @@ -216,7 +216,8 @@ public void FinishTransaction(string productJSON, string transactionID) internal static Dictionary StringPropertyToDictionary(object info) { var dictionary = new Dictionary(); - if (info == null){ + if (info == null) + { return dictionary; } diff --git a/Runtime/Stores/Android/UDP/UDPImpl.cs b/Runtime/Stores/Android/UDP/UDPImpl.cs index 2406072..0ff00b4 100644 --- a/Runtime/Stores/Android/UDP/UDPImpl.cs +++ b/Runtime/Stores/Android/UDP/UDPImpl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using UnityEngine.Purchasing.Extension; @@ -49,7 +49,7 @@ public override void RetrieveProducts(ReadOnlyCollection prod } else { - m_Logger.LogWarning("Unity IAP", "RetrieveProducts failed: " + json); + m_Logger.LogIAPWarning("RetrieveProducts failed: " + json); } }; @@ -134,11 +134,11 @@ public override void Purchase(ProductDefinition product, string developerPayload return; } - PurchaseFailureReason reason = (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), + PurchaseFailureReason reason = (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), k_Unknown); var reasonString = reason.ToString(); - var errDic = new Dictionary {["error"] = reasonString}; + var errDic = new Dictionary { ["error"] = reasonString }; if (dic.ContainsKey("purchaseInfo")) { @@ -205,7 +205,7 @@ public void EnableDebugLog(bool enable) if (storeServiceInfo != null) { var enableDebugLogging = StoreServiceInterface.GetEnableDebugLoggingMethod(); - enableDebugLogging.Invoke(null, new object[] {enable}); + enableDebugLogging.Invoke(null, new object[] { enable }); } else { @@ -224,7 +224,7 @@ private static void DictionaryToStringProperty(Dictionary dic, o foreach (var property in properties) { if (property.PropertyType == typeof(string)) - property.SetValue(info, (string) dic.GetString(property.Name), null); + property.SetValue(info, (string)dic.GetString(property.Name), null); } } diff --git a/Runtime/Stores/Android/UDP/UDPReflectionUtil.cs b/Runtime/Stores/Android/UDP/UDPReflectionUtil.cs index b6fb1b3..1e3c052 100644 --- a/Runtime/Stores/Android/UDP/UDPReflectionUtil.cs +++ b/Runtime/Stores/Android/UDP/UDPReflectionUtil.cs @@ -12,7 +12,7 @@ internal class UDPReflectionUtils static Dictionary s_assemblyTypeCache = new Dictionary(); static Dictionary s_typeCache = new Dictionary(); - static readonly string[] k_whiteListedAssemblies = {"UnityEngine", "UnityEditor", "UDP", "com.unity"}; + static readonly string[] k_whiteListedAssemblies = { "UnityEngine", "UnityEditor", "UDP", "com.unity" }; internal static Type GetTypeByName(string typeName) { diff --git a/Runtime/Stores/AppStore.cs b/Runtime/Stores/AppStore.cs index 40f025a..b740c4e 100644 --- a/Runtime/Stores/AppStore.cs +++ b/Runtime/Stores/AppStore.cs @@ -1,4 +1,4 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// The type of Native App store being used. diff --git a/Runtime/Stores/AppleAppStore/AppleAppStore.cs b/Runtime/Stores/AppleAppStore/AppleAppStore.cs index 70db77e..b1ccd9a 100644 --- a/Runtime/Stores/AppleAppStore/AppleAppStore.cs +++ b/Runtime/Stores/AppleAppStore/AppleAppStore.cs @@ -1,13 +1,13 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// Class containing store information for iOS and tvOS builds. /// - public class AppleAppStore - { + public class AppleAppStore + { /// /// The name of the store used for iOS and tvOS builds. /// - public const string Name = "AppleAppStore"; - } + public const string Name = "AppleAppStore"; + } } diff --git a/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs b/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs index 1d945ba..9a7e0ae 100644 --- a/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs +++ b/Runtime/Stores/AppleAppStore/AppleStoreImpl.cs @@ -34,26 +34,32 @@ internal class AppleStoreImpl : JSONStore, IAppleExtensions, IAppleConfiguration private string products_json; - public AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics) { + public AppleStoreImpl(IUtil util, ITelemetryDiagnostics telemetryDiagnostics) + { AppleStoreImpl.util = util; instance = this; m_TelemetryDiagnostics = telemetryDiagnostics; } - public void SetNativeStore(INativeAppleStore apple) { - base.SetNativeStore (apple); + public void SetNativeStore(INativeAppleStore apple) + { + base.SetNativeStore(apple); this.m_Native = apple; - apple.SetUnityPurchasingCallback (MessageCallback); + apple.SetUnityPurchasingCallback(MessageCallback); } - public string appReceipt { - get { + public string appReceipt + { + get + { return m_Native.appReceipt; } } - public bool canMakePayments { - get { + public bool canMakePayments + { + get + { return m_Native.canMakePayments; } } @@ -63,11 +69,14 @@ public void SetApplePromotionalPurchaseInterceptorCallback(Action callb m_PromotionalPurchaseCallback = callback; } - public bool simulateAskToBuy { - get { + public bool simulateAskToBuy + { + get + { return m_Native.simulateAskToBuy; } - set { + set + { m_Native.simulateAskToBuy = value; } } @@ -98,7 +107,7 @@ public virtual void SetStorePromotionOrder(List products) if (p != null && !string.IsNullOrEmpty(p.definition.storeSpecificId)) productIds.Add(p.definition.storeSpecificId); } - var dict = new Dictionary{ { "products", productIds } }; + var dict = new Dictionary { { "products", productIds } }; m_Native.SetStorePromotionOrder(MiniJson.JsonEncode(dict)); } @@ -113,61 +122,77 @@ public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisi m_Native.SetStorePromotionVisibility(product.definition.storeSpecificId, visibility.ToString()); } - public string GetTransactionReceiptForProduct (Product product) { - return m_Native.GetTransactionReceiptForProductId (product.definition.storeSpecificId); + public string GetTransactionReceiptForProduct(Product product) + { + return m_Native.GetTransactionReceiptForProductId(product.definition.storeSpecificId); } - public void SetApplicationUsername (string applicationUsername) + public void SetApplicationUsername(string applicationUsername) { - m_Native.SetApplicationUsername (applicationUsername); + m_Native.SetApplicationUsername(applicationUsername); } - public override void OnProductsRetrieved (string json) + 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 = JSONSerializer.DeserializeProductDescriptions (json); + var productDescriptions = JSONSerializer.DeserializeProductDescriptions(json); List finalProductDescriptions = null; this.products_json = json; // parse app receipt - if (m_Native != null) { + if (m_Native != null) + { var base64AppReceipt = m_Native.appReceipt; - if (!string.IsNullOrEmpty (base64AppReceipt)) { + if (!string.IsNullOrEmpty(base64AppReceipt)) + { AppleReceipt appleReceipt = getAppleReceiptFromBase64String(base64AppReceipt); if (appleReceipt != null && appleReceipt.inAppPurchaseReceipts != null - && appleReceipt.inAppPurchaseReceipts.Length > 0) { + && appleReceipt.inAppPurchaseReceipts.Length > 0) + { // Enrich the product descriptions with parsed receipt data - finalProductDescriptions = new List (); - foreach (var productDescription in productDescriptions) { + finalProductDescriptions = new List(); + foreach (var productDescription in productDescriptions) + { // JDRjr this Find may not be sufficient for subsciptions (or even multiple non-consumables?) - var foundReceipts = Array.FindAll (appleReceipt.inAppPurchaseReceipts, (r) => r.productID == productDescription.storeSpecificId); - if (foundReceipts == null || foundReceipts.Length == 0) { - finalProductDescriptions.Add (productDescription); - } else { + var foundReceipts = Array.FindAll(appleReceipt.inAppPurchaseReceipts, (r) => r.productID == productDescription.storeSpecificId); + if (foundReceipts == null || foundReceipts.Length == 0) + { + finalProductDescriptions.Add(productDescription); + } + else + { Array.Sort(foundReceipts, (b, a) => (a.purchaseDate.CompareTo(b.purchaseDate))); var mostRecentReceipt = foundReceipts[0]; - var productType = (AppleStoreProductType) Enum.Parse(typeof(AppleStoreProductType), mostRecentReceipt.productType.ToString()); - if (productType == AppleStoreProductType.AutoRenewingSubscription) { + 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 ( + if (new SubscriptionInfo(mostRecentReceipt, null).isExpired() == Result.True) + { + finalProductDescriptions.Add(productDescription); + } + else + { + finalProductDescriptions.Add( + new ProductDescription( productDescription.storeSpecificId, productDescription.metadata, base64AppReceipt, mostRecentReceipt.transactionID)); } - } else if (productType == AppleStoreProductType.Consumable) { - finalProductDescriptions.Add (productDescription); - } else { - finalProductDescriptions.Add ( - new ProductDescription ( + } + else if (productType == AppleStoreProductType.Consumable) + { + finalProductDescriptions.Add(productDescription); + } + else + { + finalProductDescriptions.Add( + new ProductDescription( productDescription.storeSpecificId, productDescription.metadata, base64AppReceipt, @@ -182,7 +207,7 @@ public override void OnProductsRetrieved (string json) } // Pass along the enriched product descriptions - unity.OnProductsRetrieved (finalProductDescriptions ?? productDescriptions); + unity.OnProductsRetrieved(finalProductDescriptions ?? productDescriptions); // If there is a promotional purchase callback, tell the store to intercept those purchases. if (m_PromotionalPurchaseCallback != null) @@ -191,20 +216,20 @@ public override void OnProductsRetrieved (string json) } // Indicate we are ready to start receiving payments. - m_Native.AddTransactionObserver (); + m_Native.AddTransactionObserver(); } public virtual void RestoreTransactions(Action callback) { m_RestoreCallback = callback; - m_Native.RestoreTransactions (); + m_Native.RestoreTransactions(); } public virtual void RefreshAppReceipt(Action successCallback, Action errorCallback) { m_RefreshReceiptSuccess = successCallback; m_RefreshReceiptError = errorCallback; - m_Native.RefreshAppReceipt (); + m_Native.RefreshAppReceipt(); } public void RegisterPurchaseDeferredListener(Action callback) @@ -214,14 +239,16 @@ public void RegisterPurchaseDeferredListener(Action callback) public virtual void ContinuePromotionalPurchases() { - m_Native.ContinuePromotionalPurchases (); + m_Native.ContinuePromotionalPurchases(); } - public Dictionary GetIntroductoryPriceDictionary() { + public Dictionary GetIntroductoryPriceDictionary() + { return JSONSerializer.DeserializeSubscriptionDescriptions(this.products_json); } - public Dictionary GetProductDetails() { + public Dictionary GetProductDetails() + { return JSONSerializer.DeserializeProductDetails(this.products_json); } @@ -322,74 +349,84 @@ public void OnFetchStorePromotionVisibilityFailed() } [MonoPInvokeCallback(typeof(UnityPurchasingCallback))] - private static void MessageCallback(string subject, string payload, string receipt, string transactionId) { - util.RunOnMainThread(() => { - instance.ProcessMessage (subject, payload, receipt, transactionId); + private static void MessageCallback(string subject, string payload, string receipt, string transactionId) + { + util.RunOnMainThread(() => + { + instance.ProcessMessage(subject, payload, receipt, transactionId); }); } - private void ProcessMessage(string subject, string payload, string receipt, string transactionId) { - switch (subject) { - case "OnSetupFailed": - OnSetupFailed (payload); - break; - case "OnProductsRetrieved": - OnProductsRetrieved (payload); - break; - case "OnPurchaseSucceeded": - OnPurchaseSucceeded (payload, receipt, transactionId); - 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 (); - break; + private void ProcessMessage(string subject, string payload, string receipt, string transactionId) + { + switch (subject) + { + case "OnSetupFailed": + OnSetupFailed(payload); + break; + case "OnProductsRetrieved": + OnProductsRetrieved(payload); + break; + case "OnPurchaseSucceeded": + OnPurchaseSucceeded(payload, receipt, transactionId); + 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(); + break; } } - public override void OnPurchaseSucceeded (string id, string receipt, string transactionId) { - if (isValidPurchaseState(getAppleReceiptFromBase64String(receipt), id)) { + public override void OnPurchaseSucceeded(string id, string receipt, string transactionId) + { + if (isValidPurchaseState(getAppleReceiptFromBase64String(receipt), id)) + { base.OnPurchaseSucceeded(id, receipt, transactionId); - } else { + } + else + { base.FinishTransaction(null, transactionId); } } - internal AppleReceipt getAppleReceiptFromBase64String(string receipt) { + internal AppleReceipt getAppleReceiptFromBase64String(string receipt) + { AppleReceipt appleReceipt = null; - if (!string.IsNullOrEmpty(receipt)) { - var parser = new AppleReceiptParser (); + if (!string.IsNullOrEmpty(receipt)) + { + var parser = new AppleReceiptParser(); try { - appleReceipt = parser.Parse (Convert.FromBase64String (receipt)); + appleReceipt = parser.Parse(Convert.FromBase64String(receipt)); } catch (Exception ex) { @@ -399,19 +436,24 @@ internal AppleReceipt getAppleReceiptFromBase64String(string receipt) { return appleReceipt; } - internal bool isValidPurchaseState(AppleReceipt appleReceipt, string id) { + internal bool isValidPurchaseState(AppleReceipt appleReceipt, string id) + { var isValid = true; if (appleReceipt != null && appleReceipt.inAppPurchaseReceipts != null - && appleReceipt.inAppPurchaseReceipts.Length > 0) { + && appleReceipt.inAppPurchaseReceipts.Length > 0) + { var foundReceipts = Array.FindAll(appleReceipt.inAppPurchaseReceipts, (r) => r.productID == id); - if (foundReceipts != null && foundReceipts.Length > 0) { + if (foundReceipts != null && foundReceipts.Length > 0) + { Array.Sort(foundReceipts, (b, a) => (a.purchaseDate.CompareTo(b.purchaseDate))); var mostRecentReceipt = foundReceipts[0]; - var productType = (AppleStoreProductType) Enum.Parse(typeof(AppleStoreProductType), mostRecentReceipt.productType.ToString()); - if (productType == AppleStoreProductType.AutoRenewingSubscription) { + 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) { + if (new SubscriptionInfo(mostRecentReceipt, null).isExpired() == Result.True) + { isValid = false; } } diff --git a/Runtime/Stores/AppleAppStore/FakeAppleConfiguration.cs b/Runtime/Stores/AppleAppStore/FakeAppleConfiguration.cs index 0482a93..bd5c0c3 100644 --- a/Runtime/Stores/AppleAppStore/FakeAppleConfiguration.cs +++ b/Runtime/Stores/AppleAppStore/FakeAppleConfiguration.cs @@ -1,24 +1,27 @@ -using System; +using System; namespace UnityEngine.Purchasing { - internal class FakeAppleConfiguation : IAppleConfiguration - { - public string appReceipt { - get { - return "This is a fake receipt. When running on an Apple store, a base64 encoded App Receipt would be returned"; - } - } + internal class FakeAppleConfiguation : IAppleConfiguration + { + public string appReceipt + { + get + { + return "This is a fake receipt. When running on an Apple store, a base64 encoded App Receipt would be returned"; + } + } - public bool canMakePayments { - get { - return true; - } - } + public bool canMakePayments + { + get + { + return true; + } + } - public void SetApplePromotionalPurchaseInterceptorCallback(Action callback) - { - } - } + public void SetApplePromotionalPurchaseInterceptorCallback(Action callback) + { + } + } } - diff --git a/Runtime/Stores/AppleAppStore/FakeAppleExtensions.cs b/Runtime/Stores/AppleAppStore/FakeAppleExtensions.cs index 3e9cd75..24cc13b 100644 --- a/Runtime/Stores/AppleAppStore/FakeAppleExtensions.cs +++ b/Runtime/Stores/AppleAppStore/FakeAppleExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace UnityEngine.Purchasing @@ -30,7 +30,8 @@ public void RegisterPurchaseDeferredListener(Action callback) { } - public bool simulateAskToBuy { + public bool simulateAskToBuy + { get; set; } @@ -53,11 +54,11 @@ public void SetStorePromotionVisibility(Product product, AppleStorePromotionVisi { } - public void SetApplicationUsername (string applicationUsername) + public void SetApplicationUsername(string applicationUsername) { } - public string GetTransactionReceiptForProduct (Product product) + public string GetTransactionReceiptForProduct(Product product) { return ""; } @@ -66,11 +67,13 @@ public void ContinuePromotionalPurchases() { } - public Dictionary GetIntroductoryPriceDictionary() { + public Dictionary GetIntroductoryPriceDictionary() + { return new Dictionary(); } - public Dictionary GetProductDetails() { + public Dictionary GetProductDetails() + { return new Dictionary(); } diff --git a/Runtime/Stores/AppleAppStore/IAppleConfiguration.cs b/Runtime/Stores/AppleAppStore/IAppleConfiguration.cs index 346fac6..928e4ab 100644 --- a/Runtime/Stores/AppleAppStore/IAppleConfiguration.cs +++ b/Runtime/Stores/AppleAppStore/IAppleConfiguration.cs @@ -1,37 +1,36 @@ -using System; +using System; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { - /// + /// /// Access Apple store specific configurations. - /// + /// public interface IAppleConfiguration : IStoreConfiguration - { + { /// /// Read the App Receipt from local storage. /// Returns null for iOS less than or equal to 6, may also be null on a reinstalling and require refreshing. /// string appReceipt { get; } - /// - /// Determine if the user can make payments; [SKPaymentQueue canMakePayments]. - /// - bool canMakePayments { get; } + /// + /// Determine if the user can make payments; [SKPaymentQueue canMakePayments]. + /// + bool canMakePayments { get; } - /// - /// Stores a callback that will be called when - /// the user attempts a promotional purchase - /// (directly from the Apple App Store) on - /// iOS or tvOS. - /// - /// If the callback is set, you must call - /// IAppleExtensions.ContinuePromotionalPurchases() - /// inside it in order to continue the intercepted - /// purchase(s). - /// - /// - void SetApplePromotionalPurchaseInterceptorCallback(Action callback); - } + /// + /// Stores a callback that will be called when + /// the user attempts a promotional purchase + /// (directly from the Apple App Store) on + /// iOS or tvOS. + /// + /// If the callback is set, you must call + /// IAppleExtensions.ContinuePromotionalPurchases() + /// inside it in order to continue the intercepted + /// purchase(s). + /// + /// + void SetApplePromotionalPurchaseInterceptorCallback(Action callback); + } } - diff --git a/Runtime/Stores/AppleAppStore/IAppleExtensions.cs b/Runtime/Stores/AppleAppStore/IAppleExtensions.cs index a7497c9..63dff6e 100644 --- a/Runtime/Stores/AppleAppStore/IAppleExtensions.cs +++ b/Runtime/Stores/AppleAppStore/IAppleExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace UnityEngine.Purchasing @@ -22,7 +22,7 @@ public interface IAppleExtensions : IStoreExtension /// /// The product to fetch the receipt from. /// Returns the receipt if the product has a receipt or an empty string. - string GetTransactionReceiptForProduct (Product product); + string GetTransactionReceiptForProduct(Product product); /// /// Initiate a request to Apple to restore previously made purchases. diff --git a/Runtime/Stores/AppleAppStore/MacAppStore.cs b/Runtime/Stores/AppleAppStore/MacAppStore.cs index 7dccdbe..800f7b9 100644 --- a/Runtime/Stores/AppleAppStore/MacAppStore.cs +++ b/Runtime/Stores/AppleAppStore/MacAppStore.cs @@ -1,13 +1,13 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// Class containing store information for MacOS builds. /// - public class MacAppStore - { + public class MacAppStore + { /// /// The name of the store used for MacOS builds. /// - public const string Name = "MacAppStore"; - } + public const string Name = "MacAppStore"; + } } diff --git a/Runtime/Stores/AssemblyInfo.cs b/Runtime/Stores/AssemblyInfo.cs index a91c5b3..f5408c8 100644 --- a/Runtime/Stores/AssemblyInfo.cs +++ b/Runtime/Stores/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityEditor.Purchasing")] diff --git a/Runtime/Stores/BaseStore/INativeStoreProvider.cs b/Runtime/Stores/BaseStore/INativeStoreProvider.cs index f36b460..227a898 100644 --- a/Runtime/Stores/BaseStore/INativeStoreProvider.cs +++ b/Runtime/Stores/BaseStore/INativeStoreProvider.cs @@ -5,7 +5,7 @@ namespace UnityEngine.Purchasing { internal interface INativeStoreProvider { - INativeStore GetAndroidStore (IUnityCallback callback, AppStore store, IPurchasingBinder binder, Uniject.IUtil util); + INativeStore GetAndroidStore(IUnityCallback callback, AppStore store, IPurchasingBinder binder, Uniject.IUtil util); INativeAppleStore GetStorekit(IUnityCallback callback); } } diff --git a/Runtime/Stores/BaseStore/JSONStore.cs b/Runtime/Stores/BaseStore/JSONStore.cs index 0ec575f..9e0c0fd 100644 --- a/Runtime/Stores/BaseStore/JSONStore.cs +++ b/Runtime/Stores/BaseStore/JSONStore.cs @@ -14,8 +14,10 @@ namespace UnityEngine.Purchasing /// internal class JSONStore : AbstractStore, IUnityCallback, IStoreInternal, ITransactionHistoryExtensions { - public Product[] storeCatalog { - get { + public Product[] storeCatalog + { + get + { var result = new List(); if (m_StoreCatalog != null && unity.products.all != null) { @@ -27,7 +29,8 @@ public Product[] storeCatalog { bool isProductOwned = false; if (controllerProduct.definition.type != ProductType.Consumable) { - if (controllerProduct.hasReceipt || !String.IsNullOrEmpty(controllerProduct.transactionID)) { + if (controllerProduct.hasReceipt || !String.IsNullOrEmpty(controllerProduct.transactionID)) + { isProductOwned = true; } } @@ -79,12 +82,12 @@ public void SetNativeStore(INativeStore native) void IStoreInternal.SetModule(StandardPurchasingModule module) { - if(module == null) + if (module == null) { return; } this.m_Module = module; - if(module.logger != null) + if (module.logger != null) { this.m_Logger = module.logger; } @@ -94,24 +97,24 @@ void IStoreInternal.SetModule(StandardPurchasingModule module) } } - public override void Initialize (IStoreCallback callback) + public override void Initialize(IStoreCallback callback) { this.unity = callback; - if(m_Module != null) + if (m_Module != null) { var storeName = m_Module.storeInstance.storeName; } else { - if(m_Logger != null) + if (m_Logger != null) { - m_Logger.LogWarning("UnityIAP", "JSONStore init has no reference to SPM, can't start managed store"); + m_Logger.LogIAPWarning("JSONStore init has no reference to SPM, can't start managed store"); } } } - public override void RetrieveProducts (ReadOnlyCollection products) + public override void RetrieveProducts(ReadOnlyCollection products) { m_Store.RetrieveProducts(JSONSerializer.SerializeProductDefs(products)); } @@ -131,48 +134,49 @@ internal void ProcessManagedStoreResponse(List storeProducts) } } var products = new HashSet(); - if (storeProducts != null) { + if (storeProducts != null) + { products.UnionWith(storeProducts); } - m_Store.RetrieveProducts (JSONSerializer.SerializeProductDefs (products)); + m_Store.RetrieveProducts(JSONSerializer.SerializeProductDefs(products)); } - public override void Purchase (UnityEngine.Purchasing.ProductDefinition product, string developerPayload) + public override void Purchase(UnityEngine.Purchasing.ProductDefinition product, string developerPayload) { - m_Store.Purchase (JSONSerializer.SerializeProductDef (product), developerPayload); + m_Store.Purchase(JSONSerializer.SerializeProductDef(product), developerPayload); } - public override void FinishTransaction (UnityEngine.Purchasing.ProductDefinition product, string transactionId) + public override void FinishTransaction(UnityEngine.Purchasing.ProductDefinition product, string transactionId) { // Product definitions may be null if a store tells Unity IAP about an unknown product; // Unity IAP will not have a corresponding definition but will still finish the transaction. - var def = product == null ? null : JSONSerializer.SerializeProductDef (product); - m_Store.FinishTransaction (def, transactionId); + var def = product == null ? null : JSONSerializer.SerializeProductDef(product); + m_Store.FinishTransaction(def, transactionId); } - public void OnSetupFailed (string reason) + public void OnSetupFailed(string reason) { - var r = (InitializationFailureReason) Enum.Parse (typeof(InitializationFailureReason), reason, true); - unity.OnSetupFailed (r); + var r = (InitializationFailureReason)Enum.Parse(typeof(InitializationFailureReason), reason, true); + unity.OnSetupFailed(r); } - public virtual void OnProductsRetrieved (string json) + public virtual void OnProductsRetrieved(string json) { // NB: AppleStoreImpl overrides this completely and does not call the base. - unity.OnProductsRetrieved (JSONSerializer.DeserializeProductDescriptions (json)); + unity.OnProductsRetrieved(JSONSerializer.DeserializeProductDescriptions(json)); } - public virtual void OnPurchaseSucceeded (string id, string receipt, string transactionID) + public virtual void OnPurchaseSucceeded(string id, string receipt, string transactionID) { - unity.OnPurchaseSucceeded (id, receipt, transactionID); + unity.OnPurchaseSucceeded(id, receipt, transactionID); } - public void OnPurchaseFailed (string json) + public void OnPurchaseFailed(string json) { OnPurchaseFailed(JSONSerializer.DeserializeFailureReason(json), json); } - public void OnPurchaseFailed (PurchaseFailureDescription failure, string json = null) + public void OnPurchaseFailed(PurchaseFailureDescription failure, string json = null) { m_LastPurchaseFailureDescription = failure; m_LastPurchaseErrorCode = ParseStoreSpecificPurchaseErrorCode(json); @@ -200,10 +204,10 @@ private StoreSpecificPurchaseErrorCode ParseStoreSpecificPurchaseErrorCode(strin // If the dictionary contains a storeSpecificErrorCode, return it, otherwise return Unknown. var purchaseFailureDictionary = MiniJson.JsonDecode(json) as Dictionary; - if (purchaseFailureDictionary != null && purchaseFailureDictionary.ContainsKey(k_StoreSpecificErrorCodeKey) && Enum.IsDefined(typeof(StoreSpecificPurchaseErrorCode), (string) purchaseFailureDictionary[k_StoreSpecificErrorCodeKey])) + if (purchaseFailureDictionary != null && purchaseFailureDictionary.ContainsKey(k_StoreSpecificErrorCodeKey) && Enum.IsDefined(typeof(StoreSpecificPurchaseErrorCode), (string)purchaseFailureDictionary[k_StoreSpecificErrorCodeKey])) { - string storeSpecificErrorCodeString = (string) purchaseFailureDictionary[k_StoreSpecificErrorCodeKey]; - return (StoreSpecificPurchaseErrorCode) Enum.Parse(typeof(StoreSpecificPurchaseErrorCode), + string storeSpecificErrorCodeString = (string)purchaseFailureDictionary[k_StoreSpecificErrorCodeKey]; + return (StoreSpecificPurchaseErrorCode)Enum.Parse(typeof(StoreSpecificPurchaseErrorCode), storeSpecificErrorCodeString); } return StoreSpecificPurchaseErrorCode.Unknown; diff --git a/Runtime/Stores/BaseStore/NativeStoreProvider.cs b/Runtime/Stores/BaseStore/NativeStoreProvider.cs index d3b60a7..6edca8c 100644 --- a/Runtime/Stores/BaseStore/NativeStoreProvider.cs +++ b/Runtime/Stores/BaseStore/NativeStoreProvider.cs @@ -6,7 +6,7 @@ namespace UnityEngine.Purchasing { internal class NativeStoreProvider : INativeStoreProvider { - public INativeStore GetAndroidStore (IUnityCallback callback, AppStore store, IPurchasingBinder binder, IUtil util) + public INativeStore GetAndroidStore(IUnityCallback callback, AppStore store, IPurchasingBinder binder, IUtil util) { INativeStore nativeStore; try @@ -29,18 +29,19 @@ public INativeStore GetAndroidStore (IUnityCallback callback, AppStore store, IP private INativeStore GetAndroidStoreHelper(IUnityCallback callback, AppStore store, IPurchasingBinder binder, IUtil util) { - switch (store) { + switch (store) + { case AppStore.AmazonAppStore: using (var pluginClass = new AndroidJavaClass("com.unity.purchasing.amazon.AmazonPurchasing")) { // Switch Android callbacks to the scripting thread, via ScriptingUnityCallback. - var proxy = new JavaBridge (new ScriptingUnityCallback(callback, util)); - var instance = pluginClass.CallStatic ("instance", proxy); + var proxy = new JavaBridge(new ScriptingUnityCallback(callback, util)); + var instance = pluginClass.CallStatic("instance", proxy); // Hook up our amazon specific functionality. - var extensions = new AmazonAppStoreStoreExtensions (instance); - binder.RegisterExtension (extensions); - binder.RegisterConfiguration (extensions); - return new AndroidJavaStore (instance); + var extensions = new AmazonAppStoreStoreExtensions(instance); + binder.RegisterExtension(extensions); + binder.RegisterConfiguration(extensions); + return new AndroidJavaStore(instance); } case AppStore.UDP: @@ -68,10 +69,11 @@ private INativeStore GetAndroidStoreHelper(IUnityCallback callback, AppStore sto public INativeAppleStore GetStorekit(IUnityCallback callback) { // Both tvOS and iOS use the same Objective-C linked to the XCode project. - if (Application.platform == RuntimePlatform.IPhonePlayer || Application.platform == RuntimePlatform.tvOS) { - return new iOSStoreBindings (); + if (Application.platform == RuntimePlatform.IPhonePlayer || Application.platform == RuntimePlatform.tvOS) + { + return new iOSStoreBindings(); } - return new OSXStoreBindings (); + return new OSXStoreBindings(); } } } diff --git a/Runtime/Stores/FakeStore/DialogRequest.cs b/Runtime/Stores/FakeStore/DialogRequest.cs index cccf835..ea1938a 100644 --- a/Runtime/Stores/FakeStore/DialogRequest.cs +++ b/Runtime/Stores/FakeStore/DialogRequest.cs @@ -12,6 +12,6 @@ internal class DialogRequest public string OkayButtonText; public string CancelButtonText; public List Options; - public Action Callback; + public Action Callback; } } diff --git a/Runtime/Stores/FakeStore/FakeStore.cs b/Runtime/Stores/FakeStore/FakeStore.cs index 6ab00bd..120d1ad 100644 --- a/Runtime/Stores/FakeStore/FakeStore.cs +++ b/Runtime/Stores/FakeStore/FakeStore.cs @@ -55,7 +55,7 @@ public override void Initialize(IStoreCallback biller) // INativeStore public void RetrieveProducts(string json) { - var jsonList = (List) MiniJson.JsonDecode (json); + var jsonList = (List)MiniJson.JsonDecode(json); var productDefinitions = jsonList.DecodeJSON(Name); StoreRetrieveProducts(new ReadOnlyCollection(productDefinitions.ToList())); } @@ -70,9 +70,12 @@ public void StoreRetrieveProducts(ReadOnlyCollection productD { var metadata = new ProductMetadata("$0.01", "Fake title for " + product.id, "Fake description", "USD", 0.01m); ProductCatalog catalog = ProductCatalog.LoadDefaultCatalog(); - if (catalog != null) { - foreach (var item in catalog.allProducts) { - if (item.id == product.id) { + if (catalog != null) + { + foreach (var item in catalog.allProducts) + { + if (item.id == product.id) + { metadata = new ProductMetadata(item.googlePrice.value.ToString(), item.defaultDescription.Title, item.defaultDescription.Description, "USD", item.googlePrice.value); } } @@ -96,7 +99,7 @@ public void StoreRetrieveProducts(ReadOnlyCollection productD // To mimic typical store behavior, only display RetrieveProducts dialog for developers if (!(UIMode == FakeStoreUIMode.DeveloperUser && - StartUI (productDefinitions, DialogType.RetrieveProducts, handleAllowInitializeOrRetrieveProducts))) + StartUI(productDefinitions, DialogType.RetrieveProducts, handleAllowInitializeOrRetrieveProducts))) { // Default non-UI FakeStore RetrieveProducts behavior is to succeed handleAllowInitializeOrRetrieveProducts(true, InitializationFailureReason.AppNotKnown); @@ -106,7 +109,7 @@ public void StoreRetrieveProducts(ReadOnlyCollection productD // INativeStore public void Purchase(string productJSON, string developerPayload) { - var dic = (Dictionary) MiniJson.JsonDecode (productJSON); + var dic = (Dictionary)MiniJson.JsonDecode(productJSON); object obj; string id, storeId, type; ProductType itemType; @@ -117,7 +120,7 @@ public void Purchase(string productJSON, string developerPayload) storeId = obj.ToString(); dic.TryGetValue("type", out obj); type = obj.ToString(); - if(Enum.IsDefined(typeof(ProductType), type)) + if (Enum.IsDefined(typeof(ProductType), type)) itemType = (ProductType)Enum.Parse(typeof(ProductType), type); else itemType = ProductType.Consumable; @@ -144,7 +147,7 @@ void FakePurchase(ProductDefinition product, string developerPayload) } else { - if (failureReason == (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "Unknown")) + if (failureReason == (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "Unknown")) { failureReason = PurchaseFailureReason.UserCancelled; } @@ -156,10 +159,10 @@ void FakePurchase(ProductDefinition product, string developerPayload) } }; - if (!(StartUI (product, DialogType.Purchase, handleAllowPurchase))) + if (!(StartUI(product, DialogType.Purchase, handleAllowPurchase))) { // Default non-UI FakeStore purchase behavior is to succeed - handleAllowPurchase (true, (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "Unknown")); + handleAllowPurchase(true, (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "Unknown")); } } @@ -191,7 +194,7 @@ public void RegisterPurchaseForRestore(string productId) /// Implemented by UIFakeStore derived class /// /// true, if UI was started, false otherwise. - protected virtual bool StartUI(object model, DialogType dialogType, Action callback) + protected virtual bool StartUI(object model, DialogType dialogType, Action callback) { return false; } diff --git a/Runtime/Stores/FakeStore/IFakeExtensions.cs b/Runtime/Stores/FakeStore/IFakeExtensions.cs index 0a67ebd..23aebe4 100644 --- a/Runtime/Stores/FakeStore/IFakeExtensions.cs +++ b/Runtime/Stores/FakeStore/IFakeExtensions.cs @@ -1,10 +1,10 @@ -using UnityEngine.Purchasing; +using UnityEngine.Purchasing; using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { - internal interface IFakeExtensions : IStoreExtension - { - string unavailableProductId { get; set; } - } + internal interface IFakeExtensions : IStoreExtension + { + string unavailableProductId { get; set; } + } } diff --git a/Runtime/Stores/FakeStore/UIFakeStore.cs b/Runtime/Stores/FakeStore/UIFakeStore.cs index 78e1339..931cfa6 100644 --- a/Runtime/Stores/FakeStore/UIFakeStore.cs +++ b/Runtime/Stores/FakeStore/UIFakeStore.cs @@ -13,116 +13,118 @@ namespace UnityEngine.Purchasing { - /// - /// User interface fake store. - /// - internal class UIFakeStore : FakeStore - { - const string EnvironmentDescriptionPostfix = "\n\n[Environment: FakeStore]"; - const string SuccessString = "Success"; - const int RetrieveProductsDescriptionCount = 2; - - DialogRequest m_CurrentDialog; - int m_LastSelectedDropdownIndex; + /// + /// User interface fake store. + /// + internal class UIFakeStore : FakeStore + { + const string EnvironmentDescriptionPostfix = "\n\n[Environment: FakeStore]"; + const string SuccessString = "Success"; + const int RetrieveProductsDescriptionCount = 2; + + DialogRequest m_CurrentDialog; + int m_LastSelectedDropdownIndex; GameObject m_UIFakeStoreWindowObject; - GameObject m_EventSystem; // Dynamically created. Auto-null'd by UI system. - - #pragma warning disable 0414 - IUtil m_Util; - #pragma warning restore 0414 - - public UIFakeStore() { - } - - public UIFakeStore (IUtil util) - { - m_Util = util; - } - - /// - /// Creates and displays a modal dialog UI. Note pointer events can "drill through" the - /// UI activating underlying interface elements. Consider using techniques mentioned in - /// http://forum.unity3d.com/threads/frequently-asked-ui-questions.264479/ in apps - /// to mitigate this. Shows only one at a time. - /// - /// true, if UI was started, false otherwise. - /// Store model being shown; uses dialogType to decode. - /// Dialog type. - /// Callback called when dialog dismissed. - /// The 1st type parameter. - protected override bool StartUI(object model, DialogType dialogType, Action callback) - { - List options = new List(); - // Add a default option for "Success" - options.Add(SuccessString); - - foreach (T code in Enum.GetValues(typeof(T))) - { - options.Add(code.ToString()); - } - - Action callbackWrapper = new Action ((bool result, int codeValue) => { - // TRICKY: Would prefer to use .NET 4+'s dynamic keyword over double-casting to what I know is an enum type. - T value = (T)(object)codeValue; - callback (result, value); - }); - - string title = null, okayButton = null, cancelButton = null; - if (dialogType == DialogType.Purchase) - { - title = CreatePurchaseQuestion ((ProductDefinition)model); - if (UIMode == FakeStoreUIMode.DeveloperUser) - { - // Developer UIMode is one button, one option menu, so the button must support both pass and fail - okayButton = "OK"; - } - else - { - okayButton = "Buy"; - } - } - else if (dialogType == DialogType.RetrieveProducts) - { - title = CreateRetrieveProductsQuestion ((ReadOnlyCollection)model); - okayButton = "OK"; - } - else - { - Debug.LogError ("Unrecognized DialogType " + dialogType); - } - cancelButton = "Cancel"; - - return StartUI (title, okayButton, cancelButton, options, callbackWrapper); - } - - /// - /// Helper - /// - bool StartUI(string queryText, string okayButtonText, string cancelButtonText, - List options, Action callback) - { - // One dialog at a time please - if (IsShowingDialog()) - { - return false; - } - - // Wrap this dialog request for later use - DialogRequest dr = new DialogRequest (); - dr.QueryText = queryText; - dr.OkayButtonText = okayButtonText; - dr.CancelButtonText = cancelButtonText; - dr.Options = options; - dr.Callback = callback; - - m_CurrentDialog = dr; + GameObject m_EventSystem; // Dynamically created. Auto-null'd by UI system. + +#pragma warning disable 0414 + IUtil m_Util; +#pragma warning restore 0414 + + public UIFakeStore() + { + } + + public UIFakeStore(IUtil util) + { + m_Util = util; + } + + /// + /// Creates and displays a modal dialog UI. Note pointer events can "drill through" the + /// UI activating underlying interface elements. Consider using techniques mentioned in + /// http://forum.unity3d.com/threads/frequently-asked-ui-questions.264479/ in apps + /// to mitigate this. Shows only one at a time. + /// + /// true, if UI was started, false otherwise. + /// Store model being shown; uses dialogType to decode. + /// Dialog type. + /// Callback called when dialog dismissed. + /// The 1st type parameter. + protected override bool StartUI(object model, DialogType dialogType, Action callback) + { + List options = new List(); + // Add a default option for "Success" + options.Add(SuccessString); + + foreach (T code in Enum.GetValues(typeof(T))) + { + options.Add(code.ToString()); + } + + Action callbackWrapper = new Action((bool result, int codeValue) => + { + // TRICKY: Would prefer to use .NET 4+'s dynamic keyword over double-casting to what I know is an enum type. + T value = (T)(object)codeValue; + callback(result, value); + }); + + string title = null, okayButton = null, cancelButton = null; + if (dialogType == DialogType.Purchase) + { + title = CreatePurchaseQuestion((ProductDefinition)model); + if (UIMode == FakeStoreUIMode.DeveloperUser) + { + // Developer UIMode is one button, one option menu, so the button must support both pass and fail + okayButton = "OK"; + } + else + { + okayButton = "Buy"; + } + } + else if (dialogType == DialogType.RetrieveProducts) + { + title = CreateRetrieveProductsQuestion((ReadOnlyCollection)model); + okayButton = "OK"; + } + else + { + Debug.LogError("Unrecognized DialogType " + dialogType); + } + cancelButton = "Cancel"; + + return StartUI(title, okayButton, cancelButton, options, callbackWrapper); + } + + /// + /// Helper + /// + bool StartUI(string queryText, string okayButtonText, string cancelButtonText, + List options, Action callback) + { + // One dialog at a time please + if (IsShowingDialog()) + { + return false; + } + + // Wrap this dialog request for later use + DialogRequest dr = new DialogRequest(); + dr.QueryText = queryText; + dr.OkayButtonText = okayButtonText; + dr.CancelButtonText = cancelButtonText; + dr.Options = options; + dr.Callback = callback; + + m_CurrentDialog = dr; InstantiateDialog(); - return true; - } + return true; + } private void InstantiateDialog() { @@ -208,77 +210,77 @@ private void CreateEventSystem(Transform rootTransform) m_EventSystem.transform.parent = rootTransform; } - private string CreatePurchaseQuestion(ProductDefinition definition) - { - return "Do you want to Purchase " + definition.id + "?" + EnvironmentDescriptionPostfix; - } - - private string CreateRetrieveProductsQuestion(ReadOnlyCollection definitions) - { - string title = "Do you want to initialize purchasing for products {"; - title += string.Join(", ", definitions.Take(RetrieveProductsDescriptionCount).Select(pid => pid.id).ToArray()); - if (definitions.Count > RetrieveProductsDescriptionCount) - { - title += ", ..."; - } - title += "}?" + EnvironmentDescriptionPostfix; - - return title; - } - - /// - /// Positive button clicked. For yes/no dialog will send true message. For - /// multiselect (FakeStoreUIMode.DeveloperUser) dialog may send true or false - /// message, along with chosen option. - /// - private void OkayButtonClicked() - { - bool result = false; - - // Return false if the user chose something other than Success, and is in Development mode. - // True if the "Success" option was chosen, or if this is non-Development mode. - if (m_LastSelectedDropdownIndex == 0 || UIMode != FakeStoreUIMode.DeveloperUser) - { - // Ensure we return true - result = true; - } - - int codeValue = Math.Max(0, m_LastSelectedDropdownIndex - 1); // Pop SuccessString - - m_CurrentDialog.Callback(result, codeValue); - CloseDialog(); - } - - /// - /// Negative button clicked. Sends false message. - /// - private void CancelButtonClicked() - { - int codeValue = Math.Max(0, m_LastSelectedDropdownIndex - 1); // Pop SuccessString - - // ASSUME: This is FakeStoreUIMode.StandardUser - m_CurrentDialog.Callback(false, codeValue); - CloseDialog(); - } - - private void DropdownValueChanged(int selectedItem) - { - m_LastSelectedDropdownIndex = selectedItem; - } - - private void CloseDialog() - { - m_CurrentDialog = null; + private string CreatePurchaseQuestion(ProductDefinition definition) + { + return "Do you want to Purchase " + definition.id + "?" + EnvironmentDescriptionPostfix; + } + + private string CreateRetrieveProductsQuestion(ReadOnlyCollection definitions) + { + string title = "Do you want to initialize purchasing for products {"; + title += string.Join(", ", definitions.Take(RetrieveProductsDescriptionCount).Select(pid => pid.id).ToArray()); + if (definitions.Count > RetrieveProductsDescriptionCount) + { + title += ", ..."; + } + title += "}?" + EnvironmentDescriptionPostfix; + + return title; + } + + /// + /// Positive button clicked. For yes/no dialog will send true message. For + /// multiselect (FakeStoreUIMode.DeveloperUser) dialog may send true or false + /// message, along with chosen option. + /// + private void OkayButtonClicked() + { + bool result = false; + + // Return false if the user chose something other than Success, and is in Development mode. + // True if the "Success" option was chosen, or if this is non-Development mode. + if (m_LastSelectedDropdownIndex == 0 || UIMode != FakeStoreUIMode.DeveloperUser) + { + // Ensure we return true + result = true; + } + + int codeValue = Math.Max(0, m_LastSelectedDropdownIndex - 1); // Pop SuccessString + + m_CurrentDialog.Callback(result, codeValue); + CloseDialog(); + } + + /// + /// Negative button clicked. Sends false message. + /// + private void CancelButtonClicked() + { + int codeValue = Math.Max(0, m_LastSelectedDropdownIndex - 1); // Pop SuccessString + + // ASSUME: This is FakeStoreUIMode.StandardUser + m_CurrentDialog.Callback(false, codeValue); + CloseDialog(); + } + + private void DropdownValueChanged(int selectedItem) + { + m_LastSelectedDropdownIndex = selectedItem; + } + + private void CloseDialog() + { + m_CurrentDialog = null; if (m_UIFakeStoreWindowObject != null) { GameObject.Destroy(m_UIFakeStoreWindowObject); } - } + } - public bool IsShowingDialog() - { - return m_CurrentDialog != null; - } - } + public bool IsShowingDialog() + { + return m_CurrentDialog != null; + } + } } diff --git a/Runtime/Stores/Networking/QueryHelper.cs b/Runtime/Stores/Networking/QueryHelper.cs index 78a66ee..37c65e1 100644 --- a/Runtime/Stores/Networking/QueryHelper.cs +++ b/Runtime/Stores/Networking/QueryHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -10,7 +10,8 @@ internal static string ToQueryString(this Dictionary parameters) { StringBuilder sb = new StringBuilder(); - foreach (string key in parameters.Keys) { + foreach (string key in parameters.Keys) + { string val = parameters[key].ToString(); if (val == null) diff --git a/Runtime/Stores/ProductCatalog.cs b/Runtime/Stores/ProductCatalog.cs index 187471b..2034c70 100644 --- a/Runtime/Stores/ProductCatalog.cs +++ b/Runtime/Stores/ProductCatalog.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -395,7 +395,7 @@ public class LocalizedProductDescription /// A new instance identical to this object public LocalizedProductDescription Clone() { - var desc = new LocalizedProductDescription (); + var desc = new LocalizedProductDescription(); desc.googleLocale = this.googleLocale; desc.Title = this.Title; @@ -407,11 +407,14 @@ public LocalizedProductDescription Clone() /// /// The title of the product description. /// - public string Title { - get { + public string Title + { + get + { return DecodeNonLatinCharacters(title); } - set { + set + { title = EncodeNonLatinCharacters(value); } } @@ -419,11 +422,14 @@ public string Title { /// /// The product description displayed as a string. /// - public string Description { - get { + public string Description + { + get + { return DecodeNonLatinCharacters(description); } - set { + set + { description = EncodeNonLatinCharacters(value); } } @@ -434,11 +440,15 @@ private static string EncodeNonLatinCharacters(string s) return s; var sb = new StringBuilder(); - foreach (char c in s) { - if (c > 127) { + foreach (char c in s) + { + if (c > 127) + { string encodedValue = "\\u" + ((int)c).ToString("x4"); sb.Append(encodedValue); - } else { + } + else + { sb.Append(c); } } @@ -450,7 +460,8 @@ private static string DecodeNonLatinCharacters(string s) if (s == null) return s; - return Regex.Replace(s, @"\\u(?[a-zA-Z0-9]{4})", m => { + return Regex.Replace(s, @"\\u(?[a-zA-Z0-9]{4})", m => + { return ((char)int.Parse(m.Groups["Value"].Value, NumberStyles.HexNumber)).ToString(); }); } @@ -494,23 +505,28 @@ public enum ProductCatalogPayoutType /// /// The type of the payout of the product. /// - public ProductCatalogPayoutType type { - get { + public ProductCatalogPayoutType type + { + get + { var retval = ProductCatalogPayoutType.Other; if (Enum.IsDefined(typeof(ProductCatalogPayoutType), t)) - retval = (ProductCatalogPayoutType)Enum.Parse (typeof (ProductCatalogPayoutType), t); + retval = (ProductCatalogPayoutType)Enum.Parse(typeof(ProductCatalogPayoutType), t); return retval; } - set { - t = value.ToString (); + set + { + t = value.ToString(); } } /// /// ProductCatalogPayoutType as a string. /// - public string typeString { - get { + public string typeString + { + get + { return t; } } @@ -526,13 +542,16 @@ public string typeString { /// /// The custom name for a subtype for the "Other" payout type. /// - public string subtype { - get { + public string subtype + { + get + { return st; } - set { + set + { if (value.Length > MaxSubtypeLength) - throw new ArgumentException (string.Format ("subtype should be no longer than {0} characters", MaxSubtypeLength)); + throw new ArgumentException(string.Format("subtype should be no longer than {0} characters", MaxSubtypeLength)); st = value; } } @@ -543,11 +562,14 @@ public string subtype { /// /// The quantity of payout. /// - public double quantity { - get { + public double quantity + { + get + { return q; } - set { + set + { q = value; } } @@ -562,13 +584,16 @@ public double quantity { /// /// The raw data of the payout. /// - public string data { - get { + public string data + { + get + { return d; } - set { + set + { if (value.Length > MaxDataLength) - throw new ArgumentException (string.Format ("data should be no longer than {0} characters", MaxDataLength)); + throw new ArgumentException(string.Format("data should be no longer than {0} characters", MaxDataLength)); d = value; } } @@ -657,8 +682,10 @@ public void RemovePayout(ProductCatalogPayout payout) /// Gets the list of payouts for this product. /// /// The list of payouts - public IList Payouts { - get { + public IList Payouts + { + get + { return payouts; } } @@ -669,18 +696,19 @@ public IList Payouts { /// A new instance of ProductCatalogItem identical to this. public ProductCatalogItem Clone() { - ProductCatalogItem item = new ProductCatalogItem (); + ProductCatalogItem item = new ProductCatalogItem(); item.id = this.id; item.type = this.type; - item.SetStoreIDs (this.allStoreIDs); - item.defaultDescription = this.defaultDescription.Clone (); + item.SetStoreIDs(this.allStoreIDs); + item.defaultDescription = this.defaultDescription.Clone(); item.screenshotPath = this.screenshotPath; item.applePriceTier = this.applePriceTier; item.googlePrice.value = this.googlePrice.value; item.pricingTemplateID = this.pricingTemplateID; - foreach (var desc in this.descriptions) { - item.descriptions.Add (desc.Clone ()); + foreach (var desc in this.descriptions) + { + item.descriptions.Add(desc.Clone()); } return item; @@ -713,8 +741,10 @@ public string GetStoreID(string store) /// Gets all of the StoreIds associated with this item. /// /// A collection of all store IDs for this item. - public ICollection allStoreIDs { - get { + public ICollection allStoreIDs + { + get + { return storeIDs; } } @@ -723,8 +753,10 @@ public ICollection allStoreIDs { /// Assigns or modifies a collection of StoreIDs associated with this item. /// /// The set of StoreIDs to assign or overwrite. - public void SetStoreIDs(ICollection storeIds) { - foreach (var storeId in storeIds) { + public void SetStoreIDs(ICollection storeIds) + { + foreach (var storeId in storeIds) + { storeIDs.RemoveAll((obj) => obj.store == storeId.store); if (!string.IsNullOrEmpty(storeId.id)) storeIDs.Add(new StoreID(storeId.store, storeId.id)); @@ -780,7 +812,8 @@ public void RemoveDescription(TranslationLocale locale) /// Whether or not a new locale is avalable. public bool HasAvailableLocale { - get { + get + { return Enum.GetValues(typeof(TranslationLocale)).Length > descriptions.Count + 1; // +1 for the default description } } @@ -791,7 +824,8 @@ public bool HasAvailableLocale /// The next avalable locale. public TranslationLocale NextAvailableLocale { - get { + get + { foreach (TranslationLocale l in Enum.GetValues(typeof(TranslationLocale))) { if (GetDescription(l) == null && defaultDescription.googleLocale != l) @@ -810,7 +844,8 @@ public TranslationLocale NextAvailableLocale /// A collection of all translated descriptions. public ICollection translatedDescriptions { - get { + get + { return descriptions; } } @@ -840,6 +875,12 @@ public class ProductCatalog /// Enables automatic initialization when using Codeless IAP. /// public bool enableCodelessAutoInitialization = true; + + /// + /// Enables automatic Unity Gaming Services initialization when using Codeless IAP. + /// + public bool enableUnityGamingServicesAutoInitialization; + [SerializeField] private List products = new List(); @@ -853,8 +894,9 @@ public class ProductCatalog /// public ICollection allValidProducts { - get { - return products.Where(x => (!string.IsNullOrEmpty(x.id) && x.id.Trim().Length != 0 )).ToList(); + get + { + return products.Where(x => (!string.IsNullOrEmpty(x.id) && x.id.Trim().Length != 0)).ToList(); } } diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index 399a7f3..f56c6f7 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -42,7 +42,7 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS internal ITelemetryDiagnosticsInstanceWrapper telemetryDiagnosticsInstanceWrapper { get; set; } // Map Android store enums to their public names. // Necessary because store enum names and public names almost, but not quite, match. - private static Dictionary AndroidStoreNameMap = new Dictionary () { + private static Dictionary AndroidStoreNameMap = new Dictionary() { { AppStore.AmazonAppStore, AmazonApps.Name }, { AppStore.GooglePlay, GooglePlay.Name }, { AppStore.UDP, UDP.Name}, @@ -53,7 +53,7 @@ internal class StoreInstance { internal string storeName { get; } internal IStore instance { get; } - internal StoreInstance (string name, IStore instance) + internal StoreInstance(string name, IStore instance) { this.storeName = name; this.instance = instance; @@ -103,9 +103,9 @@ public AppStore appStore /// Creates an instance of StandardPurchasingModule or retrieves the existing one. /// /// The existing instance or the one just created. - public static StandardPurchasingModule Instance () + public static StandardPurchasingModule Instance() { - return Instance (AppStore.NotSpecified); + return Instance(AppStore.NotSpecified); } /// @@ -113,38 +113,43 @@ public static StandardPurchasingModule Instance () /// /// The type of Android Store with which to create the instance. /// The existing instance or the one just created. - public static StandardPurchasingModule Instance (AppStore androidStore) + public static StandardPurchasingModule Instance(AppStore androidStore) { - if (null == ModuleInstance) { + if (null == ModuleInstance) + { var logger = UnityEngine.Debug.unityLogger; - var gameObject = new GameObject ("IAPUtil"); - Object.DontDestroyOnLoad (gameObject); + var gameObject = new GameObject("IAPUtil"); + Object.DontDestroyOnLoad(gameObject); gameObject.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector; - var util = gameObject.AddComponent (); + var util = gameObject.AddComponent(); var textAsset = (Resources.Load("BillingMode") as TextAsset); StoreConfiguration config = null; - if (null != textAsset) { - config = StoreConfiguration.Deserialize (textAsset.text); + if (null != textAsset) + { + config = StoreConfiguration.Deserialize(textAsset.text); } // No Android target specified at runtime, use the build time setting. - if (androidStore == AppStore.NotSpecified) { + if (androidStore == AppStore.NotSpecified) + { // Default to Google Play if we don't have a build time store selection. androidStore = AppStore.GooglePlay; - if (null != config) { + if (null != config) + { var buildTimeStore = config.androidStore; - if (buildTimeStore != AppStore.NotSpecified) { + if (buildTimeStore != AppStore.NotSpecified) + { androidStore = buildTimeStore; } } } - ModuleInstance = new StandardPurchasingModule ( + ModuleInstance = new StandardPurchasingModule( util, logger, - new NativeStoreProvider (), + new NativeStoreProvider(), Application.platform, androidStore, new TelemetryDiagnosticsInstanceWrapper(), @@ -163,12 +168,12 @@ public override void Configure() BindExtension(new FakeGooglePlayStoreExtensions()); BindConfiguration(new FakeAppleConfiguation()); - BindExtension(new FakeAppleExtensions ()); + BindExtension(new FakeAppleExtensions()); BindConfiguration(new FakeAmazonExtensions()); - BindExtension(new FakeAmazonExtensions ()); + BindExtension(new FakeAmazonExtensions()); - BindConfiguration(new MicrosoftConfiguration (this)); + BindConfiguration(new MicrosoftConfiguration(this)); BindExtension(new FakeMicrosoftExtensions()); BindConfiguration(this); @@ -178,7 +183,8 @@ public override void Configure() // Our store implementations are singletons, we must not attempt to instantiate // them more than once. - if (null == storeInstance) { + if (null == storeInstance) + { storeInstance = InstantiateStore(); } @@ -200,35 +206,38 @@ public override void Configure() } } - private StoreInstance InstantiateStore () + private StoreInstance InstantiateStore() { - if (useFakeStoreAlways) { - return new StoreInstance (FakeStore.Name, InstantiateFakeStore ()); + if (useFakeStoreAlways) + { + return new StoreInstance(FakeStore.Name, InstantiateFakeStore()); } - switch (m_RuntimePlatform) { - case RuntimePlatform.OSXPlayer: - m_AppStorePlatform = AppStore.MacAppStore; - return new StoreInstance (MacAppStore.Name, InstantiateApple ()); - case RuntimePlatform.IPhonePlayer: - case RuntimePlatform.tvOS: - m_AppStorePlatform = AppStore.AppleAppStore; - return new StoreInstance (AppleAppStore.Name, InstantiateApple ()); - case RuntimePlatform.Android: - switch (m_AppStorePlatform) { - case AppStore.UDP: - return new StoreInstance (AndroidStoreNameMap [m_AppStorePlatform], InstantiateUDP()); - default: - return new StoreInstance (AndroidStoreNameMap [m_AppStorePlatform], InstantiateAndroid()); - } - case RuntimePlatform.WSAPlayerARM: - case RuntimePlatform.WSAPlayerX64: - case RuntimePlatform.WSAPlayerX86: - m_AppStorePlatform = AppStore.WinRT; - return new StoreInstance (WindowsStore.Name, instantiateWindowsStore ()); + switch (m_RuntimePlatform) + { + case RuntimePlatform.OSXPlayer: + m_AppStorePlatform = AppStore.MacAppStore; + return new StoreInstance(MacAppStore.Name, InstantiateApple()); + case RuntimePlatform.IPhonePlayer: + case RuntimePlatform.tvOS: + m_AppStorePlatform = AppStore.AppleAppStore; + return new StoreInstance(AppleAppStore.Name, InstantiateApple()); + case RuntimePlatform.Android: + switch (m_AppStorePlatform) + { + case AppStore.UDP: + return new StoreInstance(AndroidStoreNameMap[m_AppStorePlatform], InstantiateUDP()); + default: + return new StoreInstance(AndroidStoreNameMap[m_AppStorePlatform], InstantiateAndroid()); + } + case RuntimePlatform.WSAPlayerARM: + case RuntimePlatform.WSAPlayerX64: + case RuntimePlatform.WSAPlayerX86: + m_AppStorePlatform = AppStore.WinRT; + return new StoreInstance(WindowsStore.Name, instantiateWindowsStore()); } m_AppStorePlatform = AppStore.fake; - return new StoreInstance (FakeStore.Name, InstantiateFakeStore ()); + return new StoreInstance(FakeStore.Name, InstantiateFakeStore()); } private IStore InstantiateAndroid() @@ -276,7 +285,7 @@ private IStore InstantiateGoogleStore() googlePlayConfiguration, googlePlayStoreExtensions, util); - util.AddPauseListener (googlePlayStore.OnPause); + util.AddPauseListener(googlePlayStore.OnPause); BindGoogleConfiguration(googlePlayConfiguration); BindGoogleExtension(googlePlayStoreExtensions); return googlePlayStore; @@ -335,20 +344,20 @@ private IStore InstantiateUDP() { var store = new UDPImpl(); BindExtension(store); - INativeUDPStore nativeUdpStore = (INativeUDPStore) GetAndroidNativeStore(store); + INativeUDPStore nativeUdpStore = (INativeUDPStore)GetAndroidNativeStore(store); store.SetNativeStore(nativeUdpStore); return store; } - private IStore InstantiateAndroidHelper (JSONStore store) + private IStore InstantiateAndroidHelper(JSONStore store) { - store.SetNativeStore (GetAndroidNativeStore(store)); + store.SetNativeStore(GetAndroidNativeStore(store)); return store; } private INativeStore GetAndroidNativeStore(JSONStore store) { - return m_NativeStoreProvider.GetAndroidStore (store, m_AppStorePlatform, m_Binder, util); + return m_NativeStoreProvider.GetAndroidStore(store, m_AppStorePlatform, m_Binder, util); } #if UNITY_PURCHASING_GPBL @@ -367,49 +376,52 @@ private IStore InstantiateGooglePlayBilling() } #endif - private IStore InstantiateApple () + private IStore InstantiateApple() { var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); var store = new MetricizedAppleStoreImpl(util, telemetryDiagnostics, telemetryMetrics); - var appleBindings = m_NativeStoreProvider.GetStorekit (store); - store.SetNativeStore (appleBindings); - BindExtension (store); + var appleBindings = m_NativeStoreProvider.GetStorekit(store); + store.SetNativeStore(appleBindings); + BindExtension(store); return store; } private WinRTStore windowsStore; - private void UseMockWindowsStore (bool value) + private void UseMockWindowsStore(bool value) { - if (null != windowsStore) { - var iap = UnityEngine.Purchasing.Default.Factory.Create (value); - windowsStore.SetWindowsIAP (iap); + if (null != windowsStore) + { + var iap = UnityEngine.Purchasing.Default.Factory.Create(value); + windowsStore.SetWindowsIAP(iap); } } - private IStore instantiateWindowsStore () + private IStore instantiateWindowsStore() { // Create a non mocked store by default. - var iap = UnityEngine.Purchasing.Default.Factory.Create (false); - windowsStore = new WinRTStore (iap, util, logger); + var iap = UnityEngine.Purchasing.Default.Factory.Create(false); + windowsStore = new WinRTStore(iap, util, logger); // Microsoft require polling for new purchases on each app foregrounding. - util.AddPauseListener (windowsStore.restoreTransactions); + util.AddPauseListener(windowsStore.restoreTransactions); return windowsStore; } - private IStore InstantiateFakeStore () + private IStore InstantiateFakeStore() { FakeStore fakeStore = null; - if (useFakeStoreUIMode != FakeStoreUIMode.Default) { + if (useFakeStoreUIMode != FakeStoreUIMode.Default) + { // To access class not available due to UnityEngine.UI conflicts with // unit-testing framework, instantiate via reflection - fakeStore = new UIFakeStore (); + fakeStore = new UIFakeStore(); fakeStore.UIMode = useFakeStoreUIMode; } - if (fakeStore == null) { - fakeStore = new FakeStore (); + if (fakeStore == null) + { + fakeStore = new FakeStore(); } return fakeStore; } @@ -423,20 +435,23 @@ private IStore InstantiateFakeStore () /// private class MicrosoftConfiguration : IMicrosoftConfiguration { - public MicrosoftConfiguration (StandardPurchasingModule module) + public MicrosoftConfiguration(StandardPurchasingModule module) { this.module = module; } private bool useMock; private StandardPurchasingModule module; - public bool useMockBillingSystem { - get { + public bool useMockBillingSystem + { + get + { return useMock; } - set { - module.UseMockWindowsStore (value); + set + { + module.UseMockWindowsStore(value); useMock = value; } } diff --git a/Runtime/Stores/StoreConfiguration.cs b/Runtime/Stores/StoreConfiguration.cs index 3093f0e..15381e7 100644 --- a/Runtime/Stores/StoreConfiguration.cs +++ b/Runtime/Stores/StoreConfiguration.cs @@ -1,34 +1,39 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine.Purchasing; -namespace UnityEngine.Purchasing { - internal class StoreConfiguration { - public AppStore androidStore { get; private set; } - public StoreConfiguration(AppStore store) { - androidStore = store; - } +namespace UnityEngine.Purchasing +{ + internal class StoreConfiguration + { + public AppStore androidStore { get; private set; } + public StoreConfiguration(AppStore store) + { + androidStore = store; + } - public static string Serialize(StoreConfiguration store) { - var dic = new Dictionary() { - { "androidStore", store.androidStore.ToString() } - }; + public static string Serialize(StoreConfiguration store) + { + var dic = new Dictionary() { + { "androidStore", store.androidStore.ToString() } + }; - return MiniJson.JsonEncode(dic); - } + return MiniJson.JsonEncode(dic); + } - /// Thrown when parsing fails - public static StoreConfiguration Deserialize(string json) { - var dic = (Dictionary) MiniJson.JsonDecode(json); + /// Thrown when parsing fails + public static StoreConfiguration Deserialize(string json) + { + var dic = (Dictionary)MiniJson.JsonDecode(json); - AppStore store; - var key = (string)dic ["androidStore"]; - if (!Enum.IsDefined (typeof(AppStore), key)) - store = AppStore.GooglePlay; - else - store = (AppStore) Enum.Parse(typeof(AppStore), (string) dic["androidStore"], true); + AppStore store; + var key = (string)dic["androidStore"]; + if (!Enum.IsDefined(typeof(AppStore), key)) + store = AppStore.GooglePlay; + else + store = (AppStore)Enum.Parse(typeof(AppStore), (string)dic["androidStore"], true); - return new StoreConfiguration(store); - } - } + return new StoreConfiguration(store); + } + } } diff --git a/Runtime/Stores/StoreSpecificPurchaseErrorCode.cs b/Runtime/Stores/StoreSpecificPurchaseErrorCode.cs index 500d1c3..ccfff59 100644 --- a/Runtime/Stores/StoreSpecificPurchaseErrorCode.cs +++ b/Runtime/Stores/StoreSpecificPurchaseErrorCode.cs @@ -1,4 +1,4 @@ -namespace UnityEngine.Purchasing +namespace UnityEngine.Purchasing { /// /// The various reasons a purchase can fail. These codes are store-specific, so that developers can have access to diff --git a/Runtime/Stores/SubscriptionManager.cs b/Runtime/Stores/SubscriptionManager.cs index 62f8e98..7fac02b 100644 --- a/Runtime/Stores/SubscriptionManager.cs +++ b/Runtime/Stores/SubscriptionManager.cs @@ -6,13 +6,15 @@ using UnityEngine.Purchasing.Security; using UnityEngine; -namespace UnityEngine.Purchasing { +namespace UnityEngine.Purchasing +{ /// /// A period of time expressed in either days, months, or years. Conveys a subscription's duration definition. /// Note this reflects the types of subscription durations settable on a subscription on supported app stores. /// - public class TimeSpanUnits { + public class TimeSpanUnits + { /// /// Discrete duration in days, if less than a month, otherwise zero. /// @@ -32,7 +34,8 @@ public class TimeSpanUnits { /// Discrete duration in days, if less than a month, otherwise zero. /// Discrete duration in months, if less than a year, otherwise zero. /// Discrete duration in years, otherwise zero. - public TimeSpanUnits (double d, int m, int y) { + public TimeSpanUnits(double d, int m, int y) + { this.days = d; this.months = m; this.years = y; @@ -47,7 +50,8 @@ public TimeSpanUnits (double d, int m, int y) { /// /// /// - public class SubscriptionManager { + public class SubscriptionManager + { private string receipt; private string productId; @@ -62,46 +66,54 @@ public class SubscriptionManager { /// Carried-over metadata from prior call to /// Triggered upon completion of the subscription update. /// Triggered upon completion of the subscription update. - public static void UpdateSubscription(Product newProduct, Product oldProduct, string developerPayload, Action appleStore, Action googleStore) { - if (oldProduct.receipt == null) { + public static void UpdateSubscription(Product newProduct, Product oldProduct, string developerPayload, Action appleStore, Action googleStore) + { + if (oldProduct.receipt == null) + { Debug.LogError("The product has not been purchased, a subscription can only be upgrade/downgrade when has already been purchased"); return; } var receipt_wrapper = (Dictionary)MiniJson.JsonDecode(oldProduct.receipt); - if (receipt_wrapper == null || !receipt_wrapper.ContainsKey("Store") || !receipt_wrapper.ContainsKey("Payload")) { + if (receipt_wrapper == null || !receipt_wrapper.ContainsKey("Store") || !receipt_wrapper.ContainsKey("Payload")) + { Debug.LogWarning("The product receipt does not contain enough information"); return; } - var store = (string)receipt_wrapper ["Store"]; - var payload = (string)receipt_wrapper ["Payload"]; + var store = (string)receipt_wrapper["Store"]; + var payload = (string)receipt_wrapper["Payload"]; - if (payload != null ) { - switch (store) { - case "GooglePlay": - { - SubscriptionManager oldSubscriptionManager = new SubscriptionManager(oldProduct, null); - SubscriptionInfo oldSubscriptionInfo = null; - try { - oldSubscriptionInfo = oldSubscriptionManager.getSubscriptionInfo(); - } catch (Exception e) { - Debug.unityLogger.LogError("Error: the product that will be updated does not have a valid receipt", e); + if (payload != null) + { + switch (store) + { + case "GooglePlay": + { + SubscriptionManager oldSubscriptionManager = new SubscriptionManager(oldProduct, null); + SubscriptionInfo oldSubscriptionInfo = null; + try + { + oldSubscriptionInfo = oldSubscriptionManager.getSubscriptionInfo(); + } + catch (Exception e) + { + Debug.unityLogger.LogError("Error: the product that will be updated does not have a valid receipt", e); + return; + } + string newSubscriptionId = newProduct.definition.storeSpecificId; + googleStore(oldSubscriptionInfo.getSubscriptionInfoJsonString(), newSubscriptionId); + return; + } + case "AppleAppStore": + case "MacAppStore": + { + appleStore(newProduct, developerPayload); + return; + } + default: + { + Debug.LogWarning("This store does not support update subscriptions"); return; } - string newSubscriptionId = newProduct.definition.storeSpecificId; - googleStore(oldSubscriptionInfo.getSubscriptionInfoJsonString(), newSubscriptionId); - return; - } - case "AppleAppStore": - case "MacAppStore": - { - appleStore(newProduct, developerPayload); - return; - } - default: - { - Debug.LogWarning("This store does not support update subscriptions"); - return; - } } } } @@ -113,12 +125,16 @@ public static void UpdateSubscription(Product newProduct, Product oldProduct, st /// Source subscription product, belonging to the same subscription group as /// Destination subscription product, belonging to the same subscription group as /// Triggered upon completion of the subscription update. - public static void UpdateSubscriptionInGooglePlayStore(Product oldProduct, Product newProduct, Action googlePlayUpdateCallback) { + public static void UpdateSubscriptionInGooglePlayStore(Product oldProduct, Product newProduct, Action googlePlayUpdateCallback) + { SubscriptionManager oldSubscriptionManager = new SubscriptionManager(oldProduct, null); SubscriptionInfo oldSubscriptionInfo = null; - try { + try + { oldSubscriptionInfo = oldSubscriptionManager.getSubscriptionInfo(); - } catch (Exception e) { + } + catch (Exception e) + { Debug.unityLogger.LogError("Error: the product that will be updated does not have a valid receipt", e); return; } @@ -133,7 +149,8 @@ public static void UpdateSubscriptionInGooglePlayStore(Product oldProduct, Produ /// Destination subscription product, belonging to the same subscription group as /// Carried-over metadata from prior call to /// Triggered upon completion of the subscription update. - public static void UpdateSubscriptionInAppleStore(Product newProduct, string developerPayload, Action appleStoreUpdateCallback) { + public static void UpdateSubscriptionInAppleStore(Product newProduct, string developerPayload, Action appleStoreUpdateCallback) + { appleStoreUpdateCallback(newProduct, developerPayload); } @@ -142,7 +159,8 @@ public static void UpdateSubscriptionInAppleStore(Product newProduct, string dev /// /// Subscription to be inspected /// From - public SubscriptionManager(Product product, string intro_json) { + public SubscriptionManager(Product product, string intro_json) + { this.receipt = product.receipt; this.productId = product.definition.storeSpecificId; this.intro_json = intro_json; @@ -154,7 +172,8 @@ public SubscriptionManager(Product product, string intro_json) { /// A Unity IAP unified receipt from /// A product identifier. /// From - public SubscriptionManager(string receipt, string id, string intro_json) { + public SubscriptionManager(string receipt, string id, string intro_json) + { this.receipt = receipt; this.productId = id; this.intro_json = intro_json; @@ -169,13 +188,15 @@ public SubscriptionManager(string receipt, string id, string intro_json) { /// My Product must have a non-null product identifier /// A supported app store must be used as my product /// My product must have - public SubscriptionInfo getSubscriptionInfo() { + public SubscriptionInfo getSubscriptionInfo() + { - if (this.receipt != null) { + if (this.receipt != null) + { var receipt_wrapper = (Dictionary)MiniJson.JsonDecode(receipt); var validPayload = receipt_wrapper.TryGetValue("Payload", out var payloadAsObject); - var validStore = receipt_wrapper.TryGetValue("Store", out var storeAsObject); + var validStore = receipt_wrapper.TryGetValue("Store", out var storeAsObject); if (validPayload && validStore) { @@ -183,27 +204,29 @@ public SubscriptionInfo getSubscriptionInfo() { var payload = payloadAsObject as string; var store = storeAsObject as string; - switch (store) { - case GooglePlay.Name: - { - return getGooglePlayStoreSubInfo(payload); - } - case AppleAppStore.Name: - case MacAppStore.Name: - { - if (this.productId == null) { - throw new NullProductIdException(); + switch (store) + { + case GooglePlay.Name: + { + return getGooglePlayStoreSubInfo(payload); + } + case AppleAppStore.Name: + case MacAppStore.Name: + { + if (this.productId == null) + { + throw new NullProductIdException(); + } + return getAppleAppStoreSubInfo(payload, this.productId); + } + case AmazonApps.Name: + { + return getAmazonAppStoreSubInfo(this.productId); + } + default: + { + throw new StoreSubscriptionInfoNotSupportedException("Store not supported: " + store); } - return getAppleAppStoreSubInfo(payload, this.productId); - } - case AmazonApps.Name: - { - return getAmazonAppStoreSubInfo(this.productId); - } - default: - { - throw new StoreSubscriptionInfoNotSupportedException("Store not supported: " + store); - } } } } @@ -212,30 +235,42 @@ public SubscriptionInfo getSubscriptionInfo() { } - private SubscriptionInfo getAmazonAppStoreSubInfo(string productId) { + private SubscriptionInfo getAmazonAppStoreSubInfo(string productId) + { return new SubscriptionInfo(productId); } - private SubscriptionInfo getAppleAppStoreSubInfo(string payload, string productId) { + private SubscriptionInfo getAppleAppStoreSubInfo(string payload, string productId) + { AppleReceipt receipt = null; var logger = UnityEngine.Debug.unityLogger; - try { + try + { receipt = new AppleReceiptParser().Parse(Convert.FromBase64String(payload)); - } catch (ArgumentException e) { - logger.Log ("Unable to parse Apple receipt", e); - } catch (Security.IAPSecurityException e) { - logger.Log ("Unable to parse Apple receipt", e); - } catch (NullReferenceException e) { - logger.Log ("Unable to parse Apple receipt", e); + } + catch (ArgumentException e) + { + logger.Log("Unable to parse Apple receipt", e); + } + catch (Security.IAPSecurityException e) + { + logger.Log("Unable to parse Apple receipt", e); + } + catch (NullReferenceException e) + { + logger.Log("Unable to parse Apple receipt", e); } List inAppPurchaseReceipts = new List(); - if (receipt != null && receipt.inAppPurchaseReceipts != null && receipt.inAppPurchaseReceipts.Length > 0) { - foreach (AppleInAppPurchaseReceipt r in receipt.inAppPurchaseReceipts) { - if (r.productID.Equals(productId)) { + if (receipt != null && receipt.inAppPurchaseReceipts != null && receipt.inAppPurchaseReceipts.Length > 0) + { + foreach (AppleInAppPurchaseReceipt r in receipt.inAppPurchaseReceipts) + { + if (r.productID.Equals(productId)) + { inAppPurchaseReceipts.Add(r); } } @@ -243,14 +278,15 @@ private SubscriptionInfo getAppleAppStoreSubInfo(string payload, string productI return inAppPurchaseReceipts.Count == 0 ? null : new SubscriptionInfo(findMostRecentReceipt(inAppPurchaseReceipts), this.intro_json); } - private AppleInAppPurchaseReceipt findMostRecentReceipt(List receipts) { + private AppleInAppPurchaseReceipt findMostRecentReceipt(List receipts) + { receipts.Sort((b, a) => (a.purchaseDate.CompareTo(b.purchaseDate))); return receipts[0]; } private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) { - var payload_wrapper = (Dictionary) MiniJson.JsonDecode(payload); + var payload_wrapper = (Dictionary)MiniJson.JsonDecode(payload); var validSkuDetailsKey = payload_wrapper.TryGetValue("skuDetails", out var skuDetailsObject); string skuDetails = null; @@ -259,13 +295,13 @@ private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) var purchaseHistorySupported = false; var original_json_payload_wrapper = - (Dictionary) MiniJson.JsonDecode((string) payload_wrapper["json"]); + (Dictionary)MiniJson.JsonDecode((string)payload_wrapper["json"]); var validIsAutoRenewingKey = original_json_payload_wrapper.TryGetValue("autoRenewing", out var autoRenewingObject); var isAutoRenewing = false; - if (validIsAutoRenewingKey) isAutoRenewing = (bool) autoRenewingObject; + if (validIsAutoRenewingKey) isAutoRenewing = (bool)autoRenewingObject; // Google specifies times in milliseconds since 1970. DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -275,7 +311,7 @@ private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) long purchaseTime = 0; - if (validPurchaseTimeKey) purchaseTime = (long) purchaseTimeObject; + if (validPurchaseTimeKey) purchaseTime = (long)purchaseTimeObject; var purchaseDate = epoch.AddMilliseconds(purchaseTime); @@ -288,30 +324,30 @@ private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) if (validDeveloperPayloadKey) { - var developerPayloadJSON = (string) developerPayloadObject; - var developerPayload_wrapper = (Dictionary) MiniJson.JsonDecode(developerPayloadJSON); + var developerPayloadJSON = (string)developerPayloadObject; + var developerPayload_wrapper = (Dictionary)MiniJson.JsonDecode(developerPayloadJSON); var validIsFreeTrialKey = developerPayload_wrapper.TryGetValue("is_free_trial", out var isFreeTrialObject); - if (validIsFreeTrialKey) isFreeTrial = (bool) isFreeTrialObject; + if (validIsFreeTrialKey) isFreeTrial = (bool)isFreeTrialObject; var validHasIntroductoryPriceKey = developerPayload_wrapper.TryGetValue("has_introductory_price_trial", out var hasIntroductoryPriceObject); - if (validHasIntroductoryPriceKey) hasIntroductoryPrice = (bool) hasIntroductoryPriceObject; + if (validHasIntroductoryPriceKey) hasIntroductoryPrice = (bool)hasIntroductoryPriceObject; var validIsUpdatedKey = developerPayload_wrapper.TryGetValue("is_updated", out var isUpdatedObject); var isUpdated = false; - if (validIsUpdatedKey) isUpdated = (bool) isUpdatedObject; + if (validIsUpdatedKey) isUpdated = (bool)isUpdatedObject; if (isUpdated) { var isValidUpdateMetaKey = developerPayload_wrapper.TryGetValue("update_subscription_metadata", out var updateMetadataObject); - if (isValidUpdateMetaKey) updateMetadata = (string) updateMetadataObject; + if (isValidUpdateMetaKey) updateMetadata = (string)updateMetadataObject; } } @@ -326,7 +362,8 @@ private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) /// A container for a Product’s subscription-related information. /// /// - public class SubscriptionInfo { + public class SubscriptionInfo + { private Result is_subscribed; private Result is_expired; private Result is_cancelled; @@ -358,33 +395,43 @@ public class SubscriptionInfo { /// unit, which can be fetched from Apple's remote service. /// Error found involving an invalid product type. /// - public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) { + public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) + { - var productType = (AppleStoreProductType) Enum.Parse(typeof(AppleStoreProductType), r.productType.ToString()); + var productType = (AppleStoreProductType)Enum.Parse(typeof(AppleStoreProductType), r.productType.ToString()); - if (productType == AppleStoreProductType.Consumable || productType == AppleStoreProductType.NonConsumable) { + if (productType == AppleStoreProductType.Consumable || productType == AppleStoreProductType.NonConsumable) + { throw new InvalidProductTypeException(); } - if (!string.IsNullOrEmpty(intro_json)) { - var intro_wrapper = (Dictionary) MiniJson.JsonDecode(intro_json); + if (!string.IsNullOrEmpty(intro_json)) + { + var intro_wrapper = (Dictionary)MiniJson.JsonDecode(intro_json); var nunit = -1; var unit = SubscriptionPeriodUnit.NotAvailable; this.introductory_price = intro_wrapper.TryGetString("introductoryPrice") + intro_wrapper.TryGetString("introductoryPriceLocale"); - if (string.IsNullOrEmpty(this.introductory_price)) { + if (string.IsNullOrEmpty(this.introductory_price)) + { this.introductory_price = "not available"; - } else { - try { + } + else + { + try + { this.introductory_price_cycles = Convert.ToInt64(intro_wrapper.TryGetString("introductoryPriceNumberOfPeriods")); nunit = Convert.ToInt32(intro_wrapper.TryGetString("numberOfUnits")); unit = (SubscriptionPeriodUnit)Convert.ToInt32(intro_wrapper.TryGetString("unit")); - } catch(Exception e) { - Debug.unityLogger.Log ("Unable to parse introductory period cycles and duration, this product does not have configuration of introductory price period", e); + } + catch (Exception e) + { + Debug.unityLogger.Log("Unable to parse introductory period cycles and duration, this product does not have configuration of introductory price period", e); unit = SubscriptionPeriodUnit.NotAvailable; } } DateTime now = DateTime.Now; - switch (unit) { + switch (unit) + { case SubscriptionPeriodUnit.Day: this.introductory_price_period = TimeSpan.FromTicks(TimeSpan.FromDays(1).Ticks * nunit); break; @@ -404,7 +451,9 @@ public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) { this.introductory_price_cycles = 0; break; } - } else { + } + else + { this.introductory_price = "not available"; this.introductory_price_period = TimeSpan.Zero; this.introductory_price_cycles = 0; @@ -418,14 +467,17 @@ public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) { this.subscriptionCancelDate = r.cancellationDate; // if the product is non-renewing subscription, apple store will not return expiration date for this product - if (productType == AppleStoreProductType.NonRenewingSubscription) { + if (productType == AppleStoreProductType.NonRenewingSubscription) + { this.is_subscribed = Result.Unsupported; this.is_expired = Result.Unsupported; this.is_cancelled = Result.Unsupported; this.is_free_trial = Result.Unsupported; this.is_auto_renewing = Result.Unsupported; this.is_introductory_price_period = Result.Unsupported; - } else { + } + else + { this.is_cancelled = (r.cancellationDate.Ticks > 0) && (r.cancellationDate.Ticks < current_date.Ticks) ? Result.True : Result.False; this.is_subscribed = r.subscriptionExpirationDate.Ticks >= current_date.Ticks ? Result.True : Result.False; this.is_expired = (r.subscriptionExpirationDate.Ticks > 0 && r.subscriptionExpirationDate.Ticks < current_date.Ticks) ? Result.True : Result.False; @@ -435,9 +487,12 @@ public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) { this.is_introductory_price_period = r.isIntroductoryPricePeriod == 1 ? Result.True : Result.False; } - if (this.is_subscribed == Result.True) { + if (this.is_subscribed == Result.True) + { this.remainedTime = r.subscriptionExpirationDate.Subtract(current_date); - } else { + } + else + { this.remainedTime = TimeSpan.Zero; } @@ -457,12 +512,14 @@ public SubscriptionInfo(AppleInAppPurchaseReceipt r, string intro_json) { /// Unsupported. Mechanism previously propagated subscription upgrade information to new subscription. /// For non-subscription product types. public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchaseDate, bool isFreeTrial, - bool hasIntroductoryPriceTrial, bool purchaseHistorySupported, string updateMetadata) { + bool hasIntroductoryPriceTrial, bool purchaseHistorySupported, string updateMetadata) + { var skuDetails_wrapper = (Dictionary)MiniJson.JsonDecode(skuDetails); var validTypeKey = skuDetails_wrapper.TryGetValue("type", out var typeObject); - if (!validTypeKey || (string)typeObject == "inapp") { + if (!validTypeKey || (string)typeObject == "inapp") + { throw new InvalidProductTypeException(); } @@ -479,23 +536,28 @@ public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchas string sub_period = null; - if (skuDetails_wrapper.ContainsKey("subscriptionPeriod")) { + if (skuDetails_wrapper.ContainsKey("subscriptionPeriod")) + { sub_period = (string)skuDetails_wrapper["subscriptionPeriod"]; } string free_trial_period = null; - if (skuDetails_wrapper.ContainsKey("freeTrialPeriod")) { + if (skuDetails_wrapper.ContainsKey("freeTrialPeriod")) + { free_trial_period = (string)skuDetails_wrapper["freeTrialPeriod"]; } string introductory_price = null; - if (skuDetails_wrapper.ContainsKey("introductoryPrice")) { + if (skuDetails_wrapper.ContainsKey("introductoryPrice")) + { introductory_price = (string)skuDetails_wrapper["introductoryPrice"]; } string introductory_price_period_string = null; - if (skuDetails_wrapper.ContainsKey("introductoryPricePeriod")) { + if (skuDetails_wrapper.ContainsKey("introductoryPricePeriod")) + { introductory_price_period_string = (string)skuDetails_wrapper["introductoryPricePeriod"]; } long introductory_price_cycles = 0; - if (skuDetails_wrapper.ContainsKey("introductoryPriceCycles")) { + if (skuDetails_wrapper.ContainsKey("introductoryPriceCycles")) + { introductory_price_cycles = (long)skuDetails_wrapper["introductoryPriceCycles"]; } @@ -505,7 +567,8 @@ public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchas this.subscriptionPeriod = computePeriodTimeSpan(parsePeriodTimeSpanUnits(sub_period)); this.freeTrialPeriod = TimeSpan.Zero; - if (isFreeTrial) { + if (isFreeTrial) + { this.freeTrialPeriod = parseTimeSpan(free_trial_period); } @@ -515,10 +578,14 @@ public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchas this.is_introductory_price_period = Result.False; TimeSpan total_introductory_duration = TimeSpan.Zero; - if (hasIntroductoryPriceTrial) { - if (introductory_price_period_string != null && introductory_price_period_string.Equals(sub_period)) { + if (hasIntroductoryPriceTrial) + { + if (introductory_price_period_string != null && introductory_price_period_string.Equals(sub_period)) + { this.introductory_price_period = this.subscriptionPeriod; - } else { + } + else + { this.introductory_price_period = parseTimeSpan(introductory_price_period_string); } // compute the total introductory duration according to the introductory price period and period cycles @@ -532,20 +599,27 @@ public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchas // this subscription is still in the extra time (the time left by the previous subscription when updated to the current one) - if (time_since_purchased <= extra_time) { + if (time_since_purchased <= extra_time) + { // this subscription is in the remaining credits from the previous updated one this.subscriptionExpireDate = purchaseDate.Add(extra_time); - } else if (time_since_purchased <= this.freeTrialPeriod.Add(extra_time)) { + } + else if (time_since_purchased <= this.freeTrialPeriod.Add(extra_time)) + { // this subscription is in the free trial period // this product will be valid until free trial ends, the beginning of next billing date this.is_free_trial = Result.True; this.subscriptionExpireDate = purchaseDate.Add(this.freeTrialPeriod.Add(extra_time)); - } else if (time_since_purchased < this.freeTrialPeriod.Add(extra_time).Add(total_introductory_duration)) { + } + else if (time_since_purchased < this.freeTrialPeriod.Add(extra_time).Add(total_introductory_duration)) + { // this subscription is in the introductory price period this.is_introductory_price_period = Result.True; DateTime introductory_price_begin_date = this.purchaseDate.Add(this.freeTrialPeriod.Add(extra_time)); this.subscriptionExpireDate = nextBillingDate(introductory_price_begin_date, parsePeriodTimeSpanUnits(introductory_price_period_string)); - } else { + } + else + { // no matter sub is cancelled or not, the expire date will be next billing date DateTime billing_begin_date = this.purchaseDate.Add(this.freeTrialPeriod.Add(extra_time).Add(total_introductory_duration)); this.subscriptionExpireDate = nextBillingDate(billing_begin_date, parsePeriodTimeSpanUnits(sub_period)); @@ -560,7 +634,8 @@ public SubscriptionInfo(string skuDetails, bool isAutoRenewing, DateTime purchas /// Note this is intended to be called internally. /// /// This subscription's product identifier - public SubscriptionInfo(string productId) { + public SubscriptionInfo(string productId) + { this.productId = productId; this.is_subscribed = Result.True; this.is_expired = Result.False; @@ -790,7 +865,8 @@ public SubscriptionInfo(string productId) { /// A JSON with keys: productId, is_free_trial, is_introductory_price_period, remaining_time_in_seconds. /// /// - public string getSubscriptionInfoJsonString() { + public string getSubscriptionInfoJsonString() + { Dictionary dict = new Dictionary(); dict.Add("productId", this.productId); dict.Add("is_free_trial", this.is_free_trial); @@ -799,52 +875,65 @@ public string getSubscriptionInfoJsonString() { return MiniJson.JsonEncode(dict); } - private DateTime nextBillingDate(DateTime billing_begin_date, TimeSpanUnits units) { + private DateTime nextBillingDate(DateTime billing_begin_date, TimeSpanUnits units) + { if (units.days == 0.0 && units.months == 0 && units.years == 0) return new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); DateTime next_billing_date = billing_begin_date; // find the next billing date that after the current date - while (DateTime.Compare(next_billing_date, DateTime.UtcNow) <= 0) { + while (DateTime.Compare(next_billing_date, DateTime.UtcNow) <= 0) + { next_billing_date = next_billing_date.AddDays(units.days).AddMonths(units.months).AddYears(units.years); } return next_billing_date; } - private TimeSpan accumulateIntroductoryDuration(TimeSpanUnits units, long cycles) { + private TimeSpan accumulateIntroductoryDuration(TimeSpanUnits units, long cycles) + { TimeSpan result = TimeSpan.Zero; - for (long i = 0; i < cycles; i++) { + for (long i = 0; i < cycles; i++) + { result = result.Add(computePeriodTimeSpan(units)); } return result; } - private TimeSpan computePeriodTimeSpan(TimeSpanUnits units) { + private TimeSpan computePeriodTimeSpan(TimeSpanUnits units) + { DateTime now = DateTime.Now; return now.AddDays(units.days).AddMonths(units.months).AddYears(units.years).Subtract(now); } - private double computeExtraTime(string metadata, double new_sku_period_in_seconds) { + private double computeExtraTime(string metadata, double new_sku_period_in_seconds) + { var wrapper = (Dictionary)MiniJson.JsonDecode(metadata); long old_sku_remaining_seconds = (long)wrapper["old_sku_remaining_seconds"]; long old_sku_price_in_micros = (long)wrapper["old_sku_price_in_micros"]; double old_sku_period_in_seconds = (parseTimeSpan((string)wrapper["old_sku_period_string"])).TotalSeconds; long new_sku_price_in_micros = (long)wrapper["new_sku_price_in_micros"]; - double result = ((((double)old_sku_remaining_seconds / (double)old_sku_period_in_seconds ) * (double)old_sku_price_in_micros) / (double)new_sku_price_in_micros) * new_sku_period_in_seconds; + double result = ((((double)old_sku_remaining_seconds / (double)old_sku_period_in_seconds) * (double)old_sku_price_in_micros) / (double)new_sku_price_in_micros) * new_sku_period_in_seconds; return result; } - private TimeSpan parseTimeSpan(string period_string) { + private TimeSpan parseTimeSpan(string period_string) + { TimeSpan result = TimeSpan.Zero; - try { + try + { result = XmlConvert.ToTimeSpan(period_string); - } catch(Exception) { - if (period_string == null || period_string.Length == 0) { + } + catch (Exception) + { + if (period_string == null || period_string.Length == 0) + { result = TimeSpan.Zero; - } else { + } + else + { // .Net "P1W" is not supported and throws a FormatException // not sure if only weekly billing contains "W" // need more testing @@ -854,26 +943,28 @@ private TimeSpan parseTimeSpan(string period_string) { return result; } - private TimeSpanUnits parsePeriodTimeSpanUnits(string time_span) { - switch (time_span) { - case "P1W": - // weekly subscription - return new TimeSpanUnits(7.0, 0, 0); - case "P1M": - // monthly subscription - return new TimeSpanUnits(0.0, 1, 0); - case "P3M": - // 3 months subscription - return new TimeSpanUnits(0.0, 3, 0); - case "P6M": - // 6 months subscription - return new TimeSpanUnits(0.0, 6, 0); - case "P1Y": - // yearly subscription - return new TimeSpanUnits(0.0, 0, 1); - default: - // seasonal subscription or duration in days - return new TimeSpanUnits((double)parseTimeSpan(time_span).Days, 0, 0); + private TimeSpanUnits parsePeriodTimeSpanUnits(string time_span) + { + switch (time_span) + { + case "P1W": + // weekly subscription + return new TimeSpanUnits(7.0, 0, 0); + case "P1M": + // monthly subscription + return new TimeSpanUnits(0.0, 1, 0); + case "P3M": + // 3 months subscription + return new TimeSpanUnits(0.0, 3, 0); + case "P6M": + // 6 months subscription + return new TimeSpanUnits(0.0, 6, 0); + case "P1Y": + // yearly subscription + return new TimeSpanUnits(0.0, 0, 1); + default: + // seasonal subscription or duration in days + return new TimeSpanUnits((double)parseTimeSpan(time_span).Days, 0, 0); } } @@ -884,7 +975,8 @@ private TimeSpanUnits parsePeriodTimeSpanUnits(string time_span) { /// /// For representing boolean values which may also be not available. /// - public enum Result { + public enum Result + { /// /// Corresponds to boolean true . /// @@ -903,7 +995,8 @@ public enum Result { /// Used internally to parse Apple receipts. Corresponds to Apple SKProductPeriodUnit. /// /// - public enum SubscriptionPeriodUnit { + public enum SubscriptionPeriodUnit + { /// /// An interval lasting one day. /// @@ -926,7 +1019,8 @@ public enum SubscriptionPeriodUnit { NotAvailable = 4, }; - enum AppleStoreProductType { + enum AppleStoreProductType + { NonConsumable = 0, Consumable = 1, NonRenewingSubscription = 2, @@ -936,7 +1030,8 @@ enum AppleStoreProductType { /// /// Error found during receipt parsing. /// - public class ReceiptParserException : System.Exception { + public class ReceiptParserException : System.Exception + { /// /// Construct an error object for receipt parsing. /// @@ -952,27 +1047,29 @@ public ReceiptParserException(string message) : base(message) { } /// /// An error was found when an invalid is provided. /// - public class InvalidProductTypeException : ReceiptParserException {} + public class InvalidProductTypeException : ReceiptParserException { } /// /// An error was found when an unexpectedly null is provided. /// - public class NullProductIdException : ReceiptParserException {} + public class NullProductIdException : ReceiptParserException { } /// /// An error was found when an unexpectedly null is provided. /// - public class NullReceiptException : ReceiptParserException {} + public class NullReceiptException : ReceiptParserException { } /// /// An error was found when an unsupported app store is provided. /// - public class StoreSubscriptionInfoNotSupportedException : ReceiptParserException { + public class StoreSubscriptionInfoNotSupportedException : ReceiptParserException + { /// /// An error was found when an unsupported app store is provided. /// /// Human readable explanation of this error - public StoreSubscriptionInfoNotSupportedException (string message) : base (message) { + public StoreSubscriptionInfoNotSupportedException(string message) : base(message) + { } } } diff --git a/Runtime/Stores/TransactionHistory/FakeTransactionHistoryExtensions.cs b/Runtime/Stores/TransactionHistory/FakeTransactionHistoryExtensions.cs index 662b7f1..e3e919f 100644 --- a/Runtime/Stores/TransactionHistory/FakeTransactionHistoryExtensions.cs +++ b/Runtime/Stores/TransactionHistory/FakeTransactionHistoryExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine.Purchasing.Extension; diff --git a/Runtime/Stores/TransactionHistory/ITransactionHistoryExtensions.cs b/Runtime/Stores/TransactionHistory/ITransactionHistoryExtensions.cs index f1fed09..1f96ec2 100644 --- a/Runtime/Stores/TransactionHistory/ITransactionHistoryExtensions.cs +++ b/Runtime/Stores/TransactionHistory/ITransactionHistoryExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine.Purchasing.Extension; diff --git a/Runtime/Stores/Util/FileReference.cs b/Runtime/Stores/Util/FileReference.cs index bd2761e..143d500 100644 --- a/Runtime/Stores/Util/FileReference.cs +++ b/Runtime/Stores/Util/FileReference.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.IO; using Uniject; -namespace UnityEngine.Purchasing { +namespace UnityEngine.Purchasing +{ /// /// File Reference that can be created with a filename. @@ -11,7 +12,8 @@ namespace UnityEngine.Purchasing { /// One use case for this class is to create a file reference to a locally cached store catalog. /// /// - internal class FileReference { + internal class FileReference + { private string m_FilePath; private ILogger m_Logger; @@ -23,7 +25,8 @@ internal class FileReference { /// Filename. /// Logger. /// Util. - internal static FileReference CreateInstance(string filename, ILogger logger, IUtil util) { + internal static FileReference CreateInstance(string filename, ILogger logger, IUtil util) + { try { var persistentDataPath = Path.Combine(util.persistentDataPath, "Unity"); @@ -48,7 +51,8 @@ internal static FileReference CreateInstance(string filename, ILogger logger, IU /// /// File path. /// Logger. - internal FileReference(string filePath, ILogger logger) { + internal FileReference(string filePath, ILogger logger) + { m_FilePath = filePath; m_Logger = logger; } @@ -57,10 +61,14 @@ internal FileReference(string filePath, ILogger logger) { /// Save the specified payload on file. /// /// Payload. - internal void Save(string payload) { - try { + internal void Save(string payload) + { + try + { File.WriteAllText(m_FilePath, payload); - } catch (Exception e) { + } + catch (Exception e) + { m_Logger.LogError("Failed persisting content", e); } } @@ -69,10 +77,14 @@ internal void Save(string payload) { /// Load the contents from the file as a string. /// /// String from file - internal string Load() { - try { + internal string Load() + { + try + { return File.ReadAllText(m_FilePath); - } catch { + } + catch + { return null; } } @@ -80,10 +92,14 @@ internal string Load() { /// /// Deletes the file /// - internal void Delete() { - try { + internal void Delete() + { + try + { File.Delete(m_FilePath); - } catch (Exception e) { + } + catch (Exception e) + { m_Logger.LogWarning("Failed deleting cached content", e); } } diff --git a/Runtime/Stores/Util/IUtil.cs b/Runtime/Stores/Util/IUtil.cs index 616d903..87847d1 100644 --- a/Runtime/Stores/Util/IUtil.cs +++ b/Runtime/Stores/Util/IUtil.cs @@ -18,7 +18,7 @@ internal interface IUtil /// WARNING: Reading from this may require special application privileges. /// string deviceUniqueIdentifier { get; } - string unityVersion { get; } + string unityVersion { get; } string userId { get; } string gameVersion { get; } UInt64 sessionId { get; } diff --git a/Runtime/Stores/Util/ProductDefinitionExtensions.cs b/Runtime/Stores/Util/ProductDefinitionExtensions.cs index a79bd37..63c9279 100644 --- a/Runtime/Stores/Util/ProductDefinitionExtensions.cs +++ b/Runtime/Stores/Util/ProductDefinitionExtensions.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Linq; using System; using System.Collections.Generic; diff --git a/Runtime/Stores/Util/UnityUtil.cs b/Runtime/Stores/Util/UnityUtil.cs index ed9ca7e..c83d307 100644 --- a/Runtime/Stores/Util/UnityUtil.cs +++ b/Runtime/Stores/Util/UnityUtil.cs @@ -5,104 +5,110 @@ namespace UnityEngine.Purchasing.Extension { - [HideInInspector] - [AddComponentMenu("")] - internal class UnityUtil : MonoBehaviour, IUtil - { - private static List s_Callbacks = new List(); - private static volatile bool s_CallbacksPending; - - private static List s_PcControlledPlatforms = new List - { - RuntimePlatform.LinuxPlayer, - RuntimePlatform.OSXEditor, - RuntimePlatform.OSXPlayer, - RuntimePlatform.WindowsEditor, - RuntimePlatform.WindowsPlayer, - }; - - public T[] GetAnyComponentsOfType() where T : class - { - GameObject[] objects = (GameObject[]) FindObjectsOfType(typeof (GameObject)); - List result = new List(); - foreach (GameObject o in objects) - { - foreach (MonoBehaviour mono in o.GetComponents()) - { - if (mono is T) - result.Add(mono as T); - } - } - - return result.ToArray(); - } - - public DateTime currentTime - { - get { return DateTime.Now; } - } - - public string persistentDataPath - { - get { return Application.persistentDataPath; } - } + [HideInInspector] + [AddComponentMenu("")] + internal class UnityUtil : MonoBehaviour, IUtil + { + private static List s_Callbacks = new List(); + private static volatile bool s_CallbacksPending; + + private static List s_PcControlledPlatforms = new List + { + RuntimePlatform.LinuxPlayer, + RuntimePlatform.OSXEditor, + RuntimePlatform.OSXPlayer, + RuntimePlatform.WindowsEditor, + RuntimePlatform.WindowsPlayer, + }; + + public T[] GetAnyComponentsOfType() where T : class + { + GameObject[] objects = (GameObject[])FindObjectsOfType(typeof(GameObject)); + List result = new List(); + foreach (GameObject o in objects) + { + foreach (MonoBehaviour mono in o.GetComponents()) + { + if (mono is T) + result.Add(mono as T); + } + } + + return result.ToArray(); + } + + public DateTime currentTime + { + get { return DateTime.Now; } + } + + public string persistentDataPath + { + get { return Application.persistentDataPath; } + } /// /// WARNING: Reading from this may require special application privileges. /// - public string deviceUniqueIdentifier { - get { return SystemInfo.deviceUniqueIdentifier; } - } - - public string unityVersion { - get { return Application.unityVersion; } - } - - public string cloudProjectId { - get { return Application.cloudProjectId; } - } - - public string userId { - get { return PlayerPrefs.GetString("unity.cloud_userid", String.Empty); } - } - - public string gameVersion { - get { return Application.version; } - } - - public UInt64 sessionId { - get { return UInt64.Parse(PlayerPrefs.GetString("unity.player_sessionid", "0")); } - } - - public RuntimePlatform platform - { - get { return Application.platform; } - } - - public bool isEditor - { - get { return Application.isEditor; } - } - - public string deviceModel - { - get { return SystemInfo.deviceModel; } - } - - public string deviceName - { - get { return SystemInfo.deviceName; } - } - - public DeviceType deviceType - { - get { return SystemInfo.deviceType; } - } - - public string operatingSystem - { - get { return SystemInfo.operatingSystem; } - } + public string deviceUniqueIdentifier + { + get { return SystemInfo.deviceUniqueIdentifier; } + } + + public string unityVersion + { + get { return Application.unityVersion; } + } + + public string cloudProjectId + { + get { return Application.cloudProjectId; } + } + + public string userId + { + get { return PlayerPrefs.GetString("unity.cloud_userid", String.Empty); } + } + + public string gameVersion + { + get { return Application.version; } + } + + public UInt64 sessionId + { + get { return UInt64.Parse(PlayerPrefs.GetString("unity.player_sessionid", "0")); } + } + + public RuntimePlatform platform + { + get { return Application.platform; } + } + + public bool isEditor + { + get { return Application.isEditor; } + } + + public string deviceModel + { + get { return SystemInfo.deviceModel; } + } + + public string deviceName + { + get { return SystemInfo.deviceName; } + } + + public DeviceType deviceType + { + get { return SystemInfo.deviceType; } + } + + public string operatingSystem + { + get { return SystemInfo.operatingSystem; } + } public int screenWidth { @@ -124,92 +130,95 @@ public string screenOrientation get { return Screen.orientation.ToString(); } } - object IUtil.InitiateCoroutine(IEnumerator start) - { - return StartCoroutine(start); - } - - void IUtil.InitiateCoroutine(IEnumerator start, int delay) - { - DelayedCoroutine(start, delay); - } - - public void RunOnMainThread(Action runnable) - { - lock (s_Callbacks) - { - s_Callbacks.Add(runnable); - s_CallbacksPending = true; - } - } - - public object GetWaitForSeconds(int seconds) - { - return new WaitForSeconds(seconds); - } - - private void Start() - { - DontDestroyOnLoad(gameObject); - } - - public static T FindInstanceOfType() where T : MonoBehaviour - { - return (T) FindObjectOfType(typeof (T)); - } - - public static T LoadResourceInstanceOfType() where T : MonoBehaviour - { - return ((GameObject) Instantiate(Resources.Load(typeof (T).ToString()))).GetComponent(); - } - - public static bool PcPlatform() - { - return s_PcControlledPlatforms.Contains(Application.platform); - } - - private IEnumerator DelayedCoroutine(IEnumerator coroutine, int delay) - { - yield return new WaitForSeconds(delay); - StartCoroutine(coroutine); - } - - private void Update() - { - if (!s_CallbacksPending) - return; - // We copy our actions to another array to avoid - // locking the queue whilst we process them. - Action[] copy; - lock (s_Callbacks) - { - if (s_Callbacks.Count == 0) - return; - - copy = new Action[s_Callbacks.Count]; - s_Callbacks.CopyTo(copy); - s_Callbacks.Clear(); - s_CallbacksPending = false; - } - - foreach (var action in copy) - action(); - } - - private List> pauseListeners = new List>(); - public void AddPauseListener(Action runnable) { - pauseListeners.Add(runnable); - } - - public void OnApplicationPause(bool paused) { - foreach (var listener in pauseListeners) { - listener(paused); - } - } - - public bool IsClassOrSubclass(Type potentialBase, Type potentialDescendant) - { - return potentialDescendant.IsSubclassOf(potentialBase) || potentialDescendant == potentialBase; - } - } + object IUtil.InitiateCoroutine(IEnumerator start) + { + return StartCoroutine(start); + } + + void IUtil.InitiateCoroutine(IEnumerator start, int delay) + { + DelayedCoroutine(start, delay); + } + + public void RunOnMainThread(Action runnable) + { + lock (s_Callbacks) + { + s_Callbacks.Add(runnable); + s_CallbacksPending = true; + } + } + + public object GetWaitForSeconds(int seconds) + { + return new WaitForSeconds(seconds); + } + + private void Start() + { + DontDestroyOnLoad(gameObject); + } + + public static T FindInstanceOfType() where T : MonoBehaviour + { + return (T)FindObjectOfType(typeof(T)); + } + + public static T LoadResourceInstanceOfType() where T : MonoBehaviour + { + return ((GameObject)Instantiate(Resources.Load(typeof(T).ToString()))).GetComponent(); + } + + public static bool PcPlatform() + { + return s_PcControlledPlatforms.Contains(Application.platform); + } + + private IEnumerator DelayedCoroutine(IEnumerator coroutine, int delay) + { + yield return new WaitForSeconds(delay); + StartCoroutine(coroutine); + } + + private void Update() + { + if (!s_CallbacksPending) + return; + // We copy our actions to another array to avoid + // locking the queue whilst we process them. + Action[] copy; + lock (s_Callbacks) + { + if (s_Callbacks.Count == 0) + return; + + copy = new Action[s_Callbacks.Count]; + s_Callbacks.CopyTo(copy); + s_Callbacks.Clear(); + s_CallbacksPending = false; + } + + foreach (var action in copy) + action(); + } + + private List> pauseListeners = new List>(); + public void AddPauseListener(Action runnable) + { + pauseListeners.Add(runnable); + } + + public void OnApplicationPause(bool paused) + { + foreach (var listener in pauseListeners) + { + listener(paused); + } + } + + public bool IsClassOrSubclass(Type potentialBase, Type potentialDescendant) + { + return potentialDescendant.IsSubclassOf(potentialBase) || potentialDescendant == potentialBase; + } + } } diff --git a/Runtime/Stores/WindowsStore/FakeMicrosoftExtensions.cs b/Runtime/Stores/WindowsStore/FakeMicrosoftExtensions.cs index 4aff909..3ea9360 100644 --- a/Runtime/Stores/WindowsStore/FakeMicrosoftExtensions.cs +++ b/Runtime/Stores/WindowsStore/FakeMicrosoftExtensions.cs @@ -3,11 +3,11 @@ namespace UnityEngine.Purchasing { - internal class FakeMicrosoftExtensions : IMicrosoftExtensions - { - public void RestoreTransactions() - { - return; - } - } + internal class FakeMicrosoftExtensions : IMicrosoftExtensions + { + public void RestoreTransactions() + { + return; + } + } } diff --git a/Runtime/Stores/WindowsStore/IMicrosoftConfiguration.cs b/Runtime/Stores/WindowsStore/IMicrosoftConfiguration.cs index b714fc9..d3b6e8b 100644 --- a/Runtime/Stores/WindowsStore/IMicrosoftConfiguration.cs +++ b/Runtime/Stores/WindowsStore/IMicrosoftConfiguration.cs @@ -1,17 +1,17 @@ -using UnityEngine.Purchasing.Extension; +using UnityEngine.Purchasing.Extension; namespace UnityEngine.Purchasing { /// /// Common interface for Universal Windows Platform configuration. /// - public interface IMicrosoftConfiguration : IStoreConfiguration - { + public interface IMicrosoftConfiguration : IStoreConfiguration + { /// /// Whether or not to use the Mock Billing system in UWP builds. /// If mock billing is used, the app can be tested before registering the app on the Windows Store. /// App releases should not be shipped with this flag set to true. /// - bool useMockBillingSystem { get; set; } - } + bool useMockBillingSystem { get; set; } + } } diff --git a/Runtime/Stores/WindowsStore/IMicrosoftExtensions.cs b/Runtime/Stores/WindowsStore/IMicrosoftExtensions.cs index f5e8045..47e1bf9 100644 --- a/Runtime/Stores/WindowsStore/IMicrosoftExtensions.cs +++ b/Runtime/Stores/WindowsStore/IMicrosoftExtensions.cs @@ -6,11 +6,11 @@ namespace UnityEngine.Purchasing /// /// Common interface for Universal Windows Platform purchasing extensions. /// - public interface IMicrosoftExtensions : IStoreExtension - { + public interface IMicrosoftExtensions : IStoreExtension + { /// /// Restores previously purchased transactions. /// - void RestoreTransactions(); - } + void RestoreTransactions(); + } } diff --git a/Runtime/Stores/WindowsStore/WinRTStore.cs b/Runtime/Stores/WindowsStore/WinRTStore.cs index 4349ba0..d5503a7 100644 --- a/Runtime/Stores/WindowsStore/WinRTStore.cs +++ b/Runtime/Stores/WindowsStore/WinRTStore.cs @@ -11,177 +11,183 @@ namespace UnityEngine.Purchasing { - /// - /// Handles Windows 8.1. - /// - internal class WinRTStore : AbstractStore, IWindowsIAPCallback, IMicrosoftExtensions + /// + /// Handles Windows 8.1. + /// + internal class WinRTStore : AbstractStore, IWindowsIAPCallback, IMicrosoftExtensions { - private IWindowsIAP win8; - private IStoreCallback callback; - private IUtil util; - private ILogger logger; + private IWindowsIAP win8; + private IStoreCallback callback; + private IUtil util; + private ILogger logger; - private bool m_CanReceivePurchases = false; + private bool m_CanReceivePurchases = false; - public WinRTStore(IWindowsIAP win8, IUtil util, ILogger logger) + public WinRTStore(IWindowsIAP win8, IUtil util, ILogger logger) { - this.win8 = win8; - this.util = util; - this.logger = logger; - } - - /// - /// Allow the windows IAP service to be swapped - /// out since the Application developer can switch - /// between sandbox/live after this store is - /// constructed. - /// - public void SetWindowsIAP(IWindowsIAP iap) + this.win8 = win8; + this.util = util; + this.logger = logger; + } + + /// + /// Allow the windows IAP service to be swapped + /// out since the Application developer can switch + /// between sandbox/live after this store is + /// constructed. + /// + public void SetWindowsIAP(IWindowsIAP iap) { - this.win8 = iap; - } + this.win8 = iap; + } - public override void Initialize(IStoreCallback biller) + public override void Initialize(IStoreCallback biller) { - this.callback = biller; - } - - public override void RetrieveProducts (ReadOnlyCollection productDefs) - { - var dummyProducts = from def in productDefs - where def.type != ProductType.Subscription - select new WinProductDescription( - def.storeSpecificId, "$0.01", - "Fake title - " + def.storeSpecificId, - "Fake description - " + def.storeSpecificId, - "USD", 0.01m, null, null, def.type == ProductType.Consumable); - win8.BuildDummyProducts(dummyProducts.ToList()); - init(0); - } - - public override void FinishTransaction (ProductDefinition product, string transactionId) - { - this.win8.FinaliseTransaction(transactionId); - } - - private void init(int delay) - { - win8.Initialize(this); - win8.RetrieveProducts(true); - } - - public override void Purchase(ProductDefinition product, string developerPayload) + this.callback = biller; + } + + public override void RetrieveProducts(ReadOnlyCollection productDefs) + { + var dummyProducts = from def in productDefs + where def.type != ProductType.Subscription + select new WinProductDescription( + def.storeSpecificId, "$0.01", + "Fake title - " + def.storeSpecificId, + "Fake description - " + def.storeSpecificId, + "USD", 0.01m, null, null, def.type == ProductType.Consumable); + win8.BuildDummyProducts(dummyProducts.ToList()); + init(0); + } + + public override void FinishTransaction(ProductDefinition product, string transactionId) { - win8.Purchase(product.storeSpecificId); - } + this.win8.FinaliseTransaction(transactionId); + } - // An Action invoked on pause/resume. - public void restoreTransactions(bool pausing) + private void init(int delay) { - if (!pausing) { - if(m_CanReceivePurchases) - { - win8.RetrieveProducts(false); - } - } - } - - public void RestoreTransactions() + win8.Initialize(this); + win8.RetrieveProducts(true); + } + + public override void Purchase(ProductDefinition product, string developerPayload) { - win8.RetrieveProducts(false); - // setting this here assumes that the Retrieve actually worked, but in the - // case where it didn't we still want to persist that the user has tried to restore - // to see if the automatic attempts (on app FG) resolve things - m_CanReceivePurchases = true; - } - - public void logError(string error) + win8.Purchase(product.storeSpecificId); + } + + // An Action invoked on pause/resume. + public void restoreTransactions(bool pausing) { - // Uncomment to get diagnostics printed on screen. - logger.LogError("Unity Purchasing", error); - } + if (!pausing) + { + if (m_CanReceivePurchases) + { + win8.RetrieveProducts(false); + } + } + } - public void OnProductListReceived(WinProductDescription[] winProducts) + public void RestoreTransactions() + { + win8.RetrieveProducts(false); + // setting this here assumes that the Retrieve actually worked, but in the + // case where it didn't we still want to persist that the user has tried to restore + // to see if the automatic attempts (on app FG) resolve things + m_CanReceivePurchases = true; + } + + public void logError(string error) { - util.RunOnMainThread(() => - { - // Convert windows products to Unity Purchasing products. - var products = from product in winProducts - let metadata = new ProductMetadata( - product.price, product.title, product.description, - product.ISOCurrencyCode, product.priceDecimal) - select new ProductDescription( - product.platformSpecificID, - metadata, - product.receipt, - product.transactionID); - // need to determine if that list includes any purchases or just products - // and then use that to set m_CanReceivePurchases - callback.OnProductsRetrieved(products.ToList()); - }); - } - - public void log(string message) { - util.RunOnMainThread(() => { - logger.Log(message); - }); - } - - public void OnPurchaseFailed(string productId, string error) + // Uncomment to get diagnostics printed on screen. + logger.LogError("Unity Purchasing", error); + } + + public void OnProductListReceived(WinProductDescription[] winProducts) { - util.RunOnMainThread(() => { - logger.LogFormat(LogType.Error, "Purchase failed: {0}, {1}", productId, error); - if("AlreadyPurchased" == error) - { - try - { - callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, - (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "DuplicateTransaction"), error)); - } - catch - { - callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, - (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "Unknown"), error)); - } - } - else if ("NotPurchased" == error) - { - callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, - (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "UserCancelled"), error)); - } - else - { - callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, - (PurchaseFailureReason) Enum.Parse(typeof(PurchaseFailureReason), "Unknown"), error)); - } - }); - } - - private static int count; - public void OnPurchaseSucceeded(string productId, string receipt, string tranId) + util.RunOnMainThread(() => + { + // Convert windows products to Unity Purchasing products. + var products = from product in winProducts + let metadata = new ProductMetadata( + product.price, product.title, product.description, + product.ISOCurrencyCode, product.priceDecimal) + select new ProductDescription( + product.platformSpecificID, + metadata, + product.receipt, + product.transactionID); + // need to determine if that list includes any purchases or just products + // and then use that to set m_CanReceivePurchases + callback.OnProductsRetrieved(products.ToList()); + }); + } + + public void log(string message) + { + util.RunOnMainThread(() => + { + logger.Log(message); + }); + } + + public void OnPurchaseFailed(string productId, string error) + { + util.RunOnMainThread(() => + { + logger.LogFormat(LogType.Error, "Purchase failed: {0}, {1}", productId, error); + if ("AlreadyPurchased" == error) + { + try + { + callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, + (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "DuplicateTransaction"), error)); + } + catch + { + callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, + (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "Unknown"), error)); + } + } + else if ("NotPurchased" == error) + { + callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, + (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "UserCancelled"), error)); + } + else + { + callback.OnPurchaseFailed(new PurchaseFailureDescription(productId, + (PurchaseFailureReason)Enum.Parse(typeof(PurchaseFailureReason), "Unknown"), error)); + } + }); + } + + private static int count; + public void OnPurchaseSucceeded(string productId, string receipt, string tranId) { - util.RunOnMainThread(() => { - m_CanReceivePurchases = true; - callback.OnPurchaseSucceeded(productId, receipt, tranId); - }); - } - - // When using an incorrect product id: - // "Exception from HRESULT: 0x805A0194" - public void OnProductListError(string message) + util.RunOnMainThread(() => + { + m_CanReceivePurchases = true; + callback.OnPurchaseSucceeded(productId, receipt, tranId); + }); + } + + // When using an incorrect product id: + // "Exception from HRESULT: 0x805A0194" + public void OnProductListError(string message) { - util.RunOnMainThread(() => { - if (message.Contains("801900CC")) + util.RunOnMainThread(() => + { + if (message.Contains("801900CC")) { - callback.OnSetupFailed(InitializationFailureReason.AppNotKnown); - } - else + callback.OnSetupFailed(InitializationFailureReason.AppNotKnown); + } + else { - logError("Unable to retrieve product listings. UnityIAP will automatically retry..."); - logError(message); - init(3000); - } - }); - } - } + logError("Unable to retrieve product listings. UnityIAP will automatically retry..."); + logError(message); + init(3000); + } + }); + } + } } diff --git a/Runtime/Stores/WindowsStore/WindowsStore.cs b/Runtime/Stores/WindowsStore/WindowsStore.cs index 74745be..9705988 100644 --- a/Runtime/Stores/WindowsStore/WindowsStore.cs +++ b/Runtime/Stores/WindowsStore/WindowsStore.cs @@ -1,17 +1,17 @@ -using System; +using System; namespace UnityEngine.Purchasing { /// /// Class containing store information for Universal Windows Platform builds. /// - public class WindowsStore - { - // The value of this constant must be left as 'WinRT' for legacy reasons. - // It may be hard coded inside Applications and elsewhere, such that changing - // it would cause breakage. + public class WindowsStore + { + // The value of this constant must be left as 'WinRT' for legacy reasons. + // It may be hard coded inside Applications and elsewhere, such that changing + // it would cause breakage. /// /// The name of the store used for Universal Windows Platform builds. /// - public const string Name = "WinRT"; - } + public const string Name = "WinRT"; + } } diff --git a/Runtime/WinRT/AssemblyInfo.cs b/Runtime/WinRT/AssemblyInfo.cs index 56acdc1..c5c3e66 100644 --- a/Runtime/WinRT/AssemblyInfo.cs +++ b/Runtime/WinRT/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityEngine.Purchasing.Stores")] diff --git a/Runtime/WinRT/CurrentApp.cs b/Runtime/WinRT/CurrentApp.cs index dc1e490..9ef0c2e 100644 --- a/Runtime/WinRT/CurrentApp.cs +++ b/Runtime/WinRT/CurrentApp.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Windows.ApplicationModel.Store; using Windows.Foundation; diff --git a/Runtime/WinRT/CurrentAppSimulator.cs b/Runtime/WinRT/CurrentAppSimulator.cs index b01a11f..3791827 100644 --- a/Runtime/WinRT/CurrentAppSimulator.cs +++ b/Runtime/WinRT/CurrentAppSimulator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -11,9 +11,11 @@ namespace UnityEngine.Purchasing.Default { class UnibillCurrentAppSimulator : ICurrentApp { - public void BuildMockProducts(List winProducts) { + public void BuildMockProducts(List winProducts) + { StorageFolder myfolder = ApplicationData.Current.LocalFolder; - if (!Exists("WindowsStoreProxy.xml")) { + if (!Exists("WindowsStoreProxy.xml")) + { myfolder.CreateFileAsync("WindowsStoreProxy.xml").AsTask().Wait(); } var file = myfolder.GetFileAsync("WindowsStoreProxy.xml").AsTask().Result; @@ -24,21 +26,26 @@ public void BuildMockProducts(List winProducts) { task.Wait(); } - private bool Exists(string fileName) { - try { + private bool Exists(string fileName) + { + try + { var task = ApplicationData.Current.LocalFolder.GetFileAsync(fileName).AsTask(); task.Wait(); - if (task.Exception == null) { + if (task.Exception == null) + { return true; } } - catch { + catch + { // Filenotfound } return false; } - private XDocument BuildDoc(List winProducts) { + private XDocument BuildDoc(List winProducts) + { XNamespace xml = "xml"; XElement CurrentApp = new XElement("CurrentApp", @@ -94,14 +101,16 @@ public IAsyncOperation ReportConsumableFulfillmentAsync(strin public IAsyncOperation RequestProductPurchaseAsync(string productId) { - return CurrentAppSimulator.RequestProductPurchaseAsync(productId); + return CurrentAppSimulator.RequestProductPurchaseAsync(productId); } - public IAsyncOperation RequestProductReceiptAsync(string productId) { + public IAsyncOperation RequestProductReceiptAsync(string productId) + { return CurrentAppSimulator.GetProductReceiptAsync(productId); } - public LicenseInformation LicenseInformation { + public LicenseInformation LicenseInformation + { get { return CurrentAppSimulator.LicenseInformation; @@ -109,7 +118,8 @@ public LicenseInformation LicenseInformation { } - public IAsyncOperation RequestAppReceiptAsync() { + public IAsyncOperation RequestAppReceiptAsync() + { return CurrentAppSimulator.GetAppReceiptAsync(); } } diff --git a/Runtime/WinRT/Factory.cs b/Runtime/WinRT/Factory.cs index 94b6795..a51ddd8 100644 --- a/Runtime/WinRT/Factory.cs +++ b/Runtime/WinRT/Factory.cs @@ -1,4 +1,4 @@ -namespace UnityEngine.Purchasing.Default +namespace UnityEngine.Purchasing.Default { /// /// A factory for creating WinRT Store objects. diff --git a/Runtime/WinRT/ICurrentApp.cs b/Runtime/WinRT/ICurrentApp.cs index ea95c35..99b1546 100644 --- a/Runtime/WinRT/ICurrentApp.cs +++ b/Runtime/WinRT/ICurrentApp.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Windows.ApplicationModel.Store; using Windows.Foundation; diff --git a/Runtime/WinRT/WinRTStore.cs b/Runtime/WinRT/WinRTStore.cs index 5ff48c8..3b0dab4 100644 --- a/Runtime/WinRT/WinRTStore.cs +++ b/Runtime/WinRT/WinRTStore.cs @@ -10,8 +10,10 @@ using Windows.UI.Core; #pragma warning disable 4014 -namespace UnityEngine.Purchasing.Default { - class WinRTStore : IWindowsIAP { +namespace UnityEngine.Purchasing.Default +{ + class WinRTStore : IWindowsIAP + { private IWindowsIAPCallback callback; private ICurrentApp currentApp; @@ -48,8 +50,10 @@ public int LoginDelay() return m_loginDelay; } - public void RetrieveProducts(bool persistent) { - RunOnUIThread(() => { + public void RetrieveProducts(bool persistent) + { + RunOnUIThread(() => + { if (LoginDelay() > 0) { PollForProducts(persistent, 0, LoginDelay(), true, false); @@ -61,9 +65,11 @@ public void RetrieveProducts(bool persistent) { }); } - private async void PollForProducts(bool persistent, int delay, int retryCount = 10, bool tryLogin = false, bool loginAttempted = false, bool productsOnly = false) { + private async void PollForProducts(bool persistent, int delay, int retryCount = 10, bool tryLogin = false, bool loginAttempted = false, bool productsOnly = false) + { await Task.Delay(delay); - try { + try + { var result = await DoRetrieveProducts(productsOnly); callback.OnProductListReceived(result); } @@ -75,10 +81,12 @@ private async void PollForProducts(bool persistent, int delay, int retryCount = // NB: persistent here is used to distinguish when this is used by restoreTransactions() so we will // keep it intact and supplement for retries on initialization // - if (persistent) { + if (persistent) + { // This seems to indicate the App is not uploaded on // the dev portal, but is undocumented by Microsoft. - if (e.Message.Contains("801900CC")) { + if (e.Message.Contains("801900CC")) + { LogError("Exception loading listing information: {0}", e.Message); callback.OnProductListError("AppNotKnown"); // JDRjr: in the main store code this is not being checked correctly @@ -87,7 +95,7 @@ private async void PollForProducts(bool persistent, int delay, int retryCount = else if (e.Message.Contains("80070525")) { LogError("PollForProducts() User not signed in error HResult = 0x{0:X} (delay = {1}, retry = {2})", e.HResult, delay, retryCount); - if((delay == 0)&&(productsOnly == false)) + if ((delay == 0) && (productsOnly == false)) { // First time failure give products only a try PollForProducts(true, 1000, retryCount, tryLogin, loginAttempted, true); @@ -99,7 +107,8 @@ private async void PollForProducts(bool persistent, int delay, int retryCount = callback.OnProductListError("801900CC because the C# code is broken"); } } - else { + else + { // other (no special handling) error codes // Wait up to 5 mins. // JDRjr: this seems like too long... @@ -145,10 +154,11 @@ private async void PollForProducts(bool persistent, int delay, int retryCount = } // end of catch() } - private async Task DoRetrieveProducts(bool productsOnly) { + private async Task DoRetrieveProducts(bool productsOnly) + { ListingInformation result = await currentApp.LoadListingInformationAsync(); - if(productsOnly == false) + if (productsOnly == false) { // We need a comprehensive list of transaction IDs for owned items. // Microsoft make this difficult by failing to provide transaction IDs @@ -160,20 +170,26 @@ private async Task DoRetrieveProducts(bool productsOnly // Add transaction IDs from our app receipt. string appReceipt = null; - try { + try + { appReceipt = await currentApp.RequestAppReceiptAsync(); - } catch (Exception e) { + } + catch (Exception e) + { LogError("Unable to retrieve app receipt:{0}", e.Message); } var receiptTransactions = XMLUtils.ParseProducts(appReceipt); - foreach (var receiptTran in receiptTransactions) { + foreach (var receiptTran in receiptTransactions) + { transactionMap[receiptTran.productId] = receiptTran.transactionId; } // Create fake transaction Ids for any owned items that we can't find transaction IDs for. - foreach (var license in currentApp.LicenseInformation.ProductLicenses) { - if (!transactionMap.ContainsKey(license.Key)) { + foreach (var license in currentApp.LicenseInformation.ProductLicenses) + { + if (!transactionMap.ContainsKey(license.Key)) + { transactionMap[license.Key] = license.Key.GetHashCode().ToString(); } } @@ -181,12 +197,12 @@ private async Task DoRetrieveProducts(bool productsOnly // Construct our products including receipts and transaction ID where owned var productDescriptions = from listing in result.ProductListings.Values - let priceDecimal = TryParsePrice(listing.FormattedPrice) - let transactionId = transactionMap.ContainsKey(listing.ProductId) ? transactionMap[listing.ProductId] : null - let receipt = transactionId == null ? null : appReceipt - select new WinProductDescription(listing.ProductId, - listing.FormattedPrice, listing.Name, string.Empty, RegionInfo.CurrentRegion.ISOCurrencySymbol, - priceDecimal, receipt, transactionId); + let priceDecimal = TryParsePrice(listing.FormattedPrice) + let transactionId = transactionMap.ContainsKey(listing.ProductId) ? transactionMap[listing.ProductId] : null + let receipt = transactionId == null ? null : appReceipt + select new WinProductDescription(listing.ProductId, + listing.FormattedPrice, listing.Name, string.Empty, RegionInfo.CurrentRegion.ISOCurrencySymbol, + priceDecimal, receipt, transactionId); // Transaction IDs tracked for finalising transactions transactionIdToProductId = transactionMap.ToDictionary(x => x.Value, x => x.Key); @@ -203,17 +219,22 @@ private async Task DoRetrieveProducts(bool productsOnly } } - private decimal TryParsePrice(string formattedPrice) { + private decimal TryParsePrice(string formattedPrice) + { decimal price = 0; decimal.TryParse(formattedPrice, NumberStyles.Currency, CultureInfo.CurrentCulture, out price); return price; } - public void Purchase(string productId) { - RunOnUIThread(async () => { - try { + public void Purchase(string productId) + { + RunOnUIThread(async () => + { + try + { var result = await currentApp.RequestProductPurchaseAsync(productId); - switch (result.Status) { + switch (result.Status) + { case ProductPurchaseStatus.Succeeded: onPurchaseSucceeded(productId, result.ReceiptXml, result.TransactionId); break; @@ -224,37 +245,46 @@ public void Purchase(string productId) { break; } } - catch (Exception e) { + catch (Exception e) + { callback.OnPurchaseFailed(productId, e.Message); } }); } - private async Task FulfillConsumable(string productId, string transactionId) { - try { + private async Task FulfillConsumable(string productId, string transactionId) + { + try + { var result = await currentApp.ReportConsumableFulfillmentAsync(productId, Guid.Parse(transactionId)); - if (FulfillmentResult.Succeeded == result) { - lock (transactionIdToProductId) { + if (FulfillmentResult.Succeeded == result) + { + lock (transactionIdToProductId) + { transactionIdToProductId.Remove(transactionId); } } // It doesn't matter if the consumption succeeds or not. // If it doesn't, it will eventually be retried automatically. } - catch (Exception e) { + catch (Exception e) + { LogError("Exception consuming {0} : {1} (non-fatal)", productId, e.Message); } } - private void LogError(string message, params object[] formatArgs) { + private void LogError(string message, params object[] formatArgs) + { callback.logError(string.Format("UnityIAPWin8:" + message, formatArgs)); } - private void onPurchaseSucceeded(string productId, string receipt, Guid transactionId) { + private void onPurchaseSucceeded(string productId, string receipt, Guid transactionId) + { var tranId = transactionId.ToString(); // Make a note of which product this transaction pertains to. - lock (transactionIdToProductId) { + lock (transactionIdToProductId) + { transactionIdToProductId[tranId] = productId; } callback.OnPurchaseSucceeded(productId, receipt, tranId); @@ -284,7 +314,8 @@ public void FinaliseTransaction(string transactionId) private static void RunOnUIThread(Action a) { - CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => { + CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => + { a(); }); } diff --git a/Runtime/WinRT/XMLUtils.cs b/Runtime/WinRT/XMLUtils.cs index ef528db..5fdeca9 100644 --- a/Runtime/WinRT/XMLUtils.cs +++ b/Runtime/WinRT/XMLUtils.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -29,7 +29,8 @@ public static IEnumerable ParseProducts(string appReceipt) { var xml = XElement.Parse(appReceipt); return from product in xml.Descendants("ProductReceipt") - select new TransactionInfo() { + select new TransactionInfo() + { productId = (string)product.Attribute("ProductId"), transactionId = (string)product.Attribute("Id") }; diff --git a/Runtime/WinRTCore/AssemblyInfo.cs.cs b/Runtime/WinRTCore/AssemblyInfo.cs.cs index e248f46..0d8b3ec 100644 --- a/Runtime/WinRTCore/AssemblyInfo.cs.cs +++ b/Runtime/WinRTCore/AssemblyInfo.cs.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityEngine.Purchasing.WinRT")] diff --git a/Runtime/WinRTCore/IWinRT.cs b/Runtime/WinRTCore/IWinRT.cs index af6bf73..9cf519a 100644 --- a/Runtime/WinRTCore/IWinRT.cs +++ b/Runtime/WinRTCore/IWinRT.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Collections.ObjectModel; namespace UnityEngine.Purchasing.Default diff --git a/Runtime/WinRTCore/WinProductDescription.cs b/Runtime/WinRTCore/WinProductDescription.cs index 5e4d6c0..7aa0715 100644 --- a/Runtime/WinRTCore/WinProductDescription.cs +++ b/Runtime/WinRTCore/WinProductDescription.cs @@ -1,11 +1,13 @@ -namespace UnityEngine.Purchasing.Default { +namespace UnityEngine.Purchasing.Default +{ /// /// A common format for Billing Subsystems to use to /// describe available In App Purchases to the Biller, /// including purchase state via Receipt and Transaction /// Identifiers. /// - public class WinProductDescription { + public class WinProductDescription + { /// /// The product's specific ID on the Windows Store. /// @@ -63,8 +65,9 @@ public class WinProductDescription { /// The product's purchase receipt. /// The product's purchase transaction ID. /// Whether or not the product is consumable. - public WinProductDescription (string id, string price, string title, string description, - string isoCode, decimal priceD, string receipt = null, string transactionId = null, bool consumable = false) { + public WinProductDescription(string id, string price, string title, string description, + string isoCode, decimal priceD, string receipt = null, string transactionId = null, bool consumable = false) + { platformSpecificID = id; this.price = price; this.title = title; diff --git a/Runtime/WinRTStub/AssemblyInfo.cs b/Runtime/WinRTStub/AssemblyInfo.cs index 56acdc1..c5c3e66 100644 --- a/Runtime/WinRTStub/AssemblyInfo.cs +++ b/Runtime/WinRTStub/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityEngine.Purchasing.Stores")] diff --git a/Runtime/WinRTStub/Factory.cs b/Runtime/WinRTStub/Factory.cs index 5a31fea..e77bb5a 100644 --- a/Runtime/WinRTStub/Factory.cs +++ b/Runtime/WinRTStub/Factory.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace UnityEngine.Purchasing.Default { @@ -18,4 +18,3 @@ public static IWindowsIAP Create(bool mocked) } } } - diff --git a/Samples~/01 BuyingConsumables/BuyingConsumables.cs b/Samples~/01 BuyingConsumables/BuyingConsumables.cs index a5508aa..6884655 100644 --- a/Samples~/01 BuyingConsumables/BuyingConsumables.cs +++ b/Samples~/01 BuyingConsumables/BuyingConsumables.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.Serialization; diff --git a/Samples~/02 BuyingSubscription/BuyingSubscription.cs b/Samples~/02 BuyingSubscription/BuyingSubscription.cs index d33aefd..2a30fd5 100644 --- a/Samples~/02 BuyingSubscription/BuyingSubscription.cs +++ b/Samples~/02 BuyingSubscription/BuyingSubscription.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; diff --git a/Samples~/02 BuyingSubscription/README.md b/Samples~/02 BuyingSubscription/README.md index 5c01aa3..091d2f0 100644 --- a/Samples~/02 BuyingSubscription/README.md +++ b/Samples~/02 BuyingSubscription/README.md @@ -2,7 +2,7 @@ README - In-App Purchasing Sample Scenes - Buying Subscription In this sample, you will see how to handle subscription purchases and use the `SubscriptionManager` class to retrieve information about a subscription. The `SubscriptionManager` only supports the App Store, Google Play Store, and Amazon Store. - + This sample uses a fake store for its transactions, to use a real store like the App Store or the Google Play Store, you would need to register your application and add In-App Purchases. For more information, follow the documentation for one of our [supported stores](https://docs.unity3d.com/Packages/com.unity.purchasing@3.1/manual/UnityIAPSettingUp.html). Keep in mind that in this sample, product identifiers are kept in the `BuyingConsumables.cs` file. ### Subscription @@ -12,4 +12,4 @@ Users can access the Product for a finite period of time. Subscription Products Examples: * Monthly access to an online game * VIP status granting daily bonuses -* A free trial \ No newline at end of file +* A free trial diff --git a/Samples~/03 FetchingAdditionalProducts/FetchingAdditionalProducts.cs b/Samples~/03 FetchingAdditionalProducts/FetchingAdditionalProducts.cs index 4014f1a..be56699 100644 --- a/Samples~/03 FetchingAdditionalProducts/FetchingAdditionalProducts.cs +++ b/Samples~/03 FetchingAdditionalProducts/FetchingAdditionalProducts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; diff --git a/Samples~/05 LocalReceiptValidation/LocalReceiptValidation.cs b/Samples~/05 LocalReceiptValidation/LocalReceiptValidation.cs index 8edf6b0..5c9facd 100644 --- a/Samples~/05 LocalReceiptValidation/LocalReceiptValidation.cs +++ b/Samples~/05 LocalReceiptValidation/LocalReceiptValidation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; diff --git a/Samples~/06 InitializeGamingServices/.sample.json b/Samples~/06 InitializeGamingServices/.sample.json new file mode 100644 index 0000000..373ef08 --- /dev/null +++ b/Samples~/06 InitializeGamingServices/.sample.json @@ -0,0 +1,5 @@ +{ + "displayName": "06 Initialize Unity Gaming Services", + "description": "This sample showcases how to initialize Unity Gaming Services using the Services Core API", + "createSeparatePackage": false +} diff --git a/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs index 52bfc63..406e4f8 100644 --- a/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs +++ b/Samples~/06 InitializeGamingServices/InitializeGamingServices.cs @@ -1,4 +1,4 @@ -using System; +using System; using Unity.Services.Core; using Unity.Services.Core.Environments; using UnityEngine; diff --git a/Samples~/Apple App Store - 01 RefreshingAppReceipt/README.md b/Samples~/Apple App Store - 01 RefreshingAppReceipt/README.md index 62eceaf..fd12fb9 100644 --- a/Samples~/Apple App Store - 01 RefreshingAppReceipt/README.md +++ b/Samples~/Apple App Store - 01 RefreshingAppReceipt/README.md @@ -13,7 +13,7 @@ manually check for new purchases without creating new transactions, in contrast 4. Build your project for `iOS`. 1. If you are using a simulator with Xcode 12+, follow these [instructions](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode) to set up StoreKit Testing. - + ## Refreshing App Receipts Using `RefreshAppReceipt` will prompt the user to enter their Apple login password. diff --git a/Samples~/Apple App Store - 01 RefreshingAppReceipt/RefreshingAppReceipt.cs b/Samples~/Apple App Store - 01 RefreshingAppReceipt/RefreshingAppReceipt.cs index e05797e..70ab4e5 100644 --- a/Samples~/Apple App Store - 01 RefreshingAppReceipt/RefreshingAppReceipt.cs +++ b/Samples~/Apple App Store - 01 RefreshingAppReceipt/RefreshingAppReceipt.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 02 RestoringTransactions/RestoringTransactions.cs b/Samples~/Apple App Store - 02 RestoringTransactions/RestoringTransactions.cs index 470f7ab..1004987 100644 --- a/Samples~/Apple App Store - 02 RestoringTransactions/RestoringTransactions.cs +++ b/Samples~/Apple App Store - 02 RestoringTransactions/RestoringTransactions.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 03 HandlingDeferredPurchases/HandlingDeferredPurchases.cs b/Samples~/Apple App Store - 03 HandlingDeferredPurchases/HandlingDeferredPurchases.cs index a9b3cf9..1d40da3 100644 --- a/Samples~/Apple App Store - 03 HandlingDeferredPurchases/HandlingDeferredPurchases.cs +++ b/Samples~/Apple App Store - 03 HandlingDeferredPurchases/HandlingDeferredPurchases.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 04 RetrievingProductReceipt/RetrievingProductReceipt.cs b/Samples~/Apple App Store - 04 RetrievingProductReceipt/RetrievingProductReceipt.cs index cdf6253..7f7c871 100644 --- a/Samples~/Apple App Store - 04 RetrievingProductReceipt/RetrievingProductReceipt.cs +++ b/Samples~/Apple App Store - 04 RetrievingProductReceipt/RetrievingProductReceipt.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 05 FraudDetection/FraudDetection.cs b/Samples~/Apple App Store - 05 FraudDetection/FraudDetection.cs index 400a124..611ebb1 100644 --- a/Samples~/Apple App Store - 05 FraudDetection/FraudDetection.cs +++ b/Samples~/Apple App Store - 05 FraudDetection/FraudDetection.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; diff --git a/Samples~/Apple App Store - 06 GettingIntroductoryPrices/GettingIntroductoryPrices.cs b/Samples~/Apple App Store - 06 GettingIntroductoryPrices/GettingIntroductoryPrices.cs index 686ac5a..855a673 100644 --- a/Samples~/Apple App Store - 06 GettingIntroductoryPrices/GettingIntroductoryPrices.cs +++ b/Samples~/Apple App Store - 06 GettingIntroductoryPrices/GettingIntroductoryPrices.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 07 gettingProductDetails/GettingProductDetails.cs b/Samples~/Apple App Store - 07 gettingProductDetails/GettingProductDetails.cs index 0e0771f..9e91486 100644 --- a/Samples~/Apple App Store - 07 gettingProductDetails/GettingProductDetails.cs +++ b/Samples~/Apple App Store - 07 gettingProductDetails/GettingProductDetails.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 08 PromptingProducts/PromotingProducts.cs b/Samples~/Apple App Store - 08 PromptingProducts/PromotingProducts.cs index 69e4001..979d1ae 100644 --- a/Samples~/Apple App Store - 08 PromptingProducts/PromotingProducts.cs +++ b/Samples~/Apple App Store - 08 PromptingProducts/PromotingProducts.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; diff --git a/Samples~/Apple App Store - 08 PromptingProducts/README.md b/Samples~/Apple App Store - 08 PromptingProducts/README.md index 092554e..ee0f018 100644 --- a/Samples~/Apple App Store - 08 PromptingProducts/README.md +++ b/Samples~/Apple App Store - 08 PromptingProducts/README.md @@ -14,7 +14,7 @@ and `IAppleConfiguration.SetApplePromotionalPurchaseInterceptorCallback`. 4. Build your project for `iOS`. 1. If you are using a simulator with Xcode 12+, follow these [instructions](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode) to set up StoreKit Testing. - + ## Promoting Products From [Apple's Documentation](https://developer.apple.com/app-store/promoting-in-app-purchases/): diff --git a/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/PresentCodeRedemptionSheet.cs b/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/PresentCodeRedemptionSheet.cs index 438cde0..0bb0f0f 100644 --- a/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/PresentCodeRedemptionSheet.cs +++ b/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/PresentCodeRedemptionSheet.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/README.md b/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/README.md index 7f5a544..a2ca272 100644 --- a/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/README.md +++ b/Samples~/Apple App Store - 09 PresentCodeRedemptionSheet/README.md @@ -24,6 +24,6 @@ Using `PresentRedemptionSheet` on a device, including on the Sandbox, will promp See the documentation for [the Unity IAP extension to present the code redemption sheet](http://docs.unity3d.com/Packages/com.unity.purchasing@4.0/api/UnityEngine.Purchasing.IAppleExtensions.html#UnityEngine_Purchasing_IAppleExtensions_PresentCodeRedemptionSheet). Also see the relevant [Apple API documentation](https://developer.apple.com/documentation/storekit/original_api_for_in-app_purchase/subscriptions_and_offers/implementing_offer_codes_in_your_app) for more context on this feature. And see the [iOS & Mac App Stores document](https://docs.unity3d.com/Packages/com.unity.purchasing@4.0/manual/UnityIAPiOSMAS.html) -for setting up an Apple project with Unity IAP. +for setting up an Apple project with Unity IAP. -See [this tip for testing offer codes end-to-end](https://developer.apple.com/forums/thread/70426), using the PRODUCTION Apple App Store server. \ No newline at end of file +See [this tip for testing offer codes end-to-end](https://developer.apple.com/forums/thread/70426), using the PRODUCTION Apple App Store server. diff --git a/Samples~/Apple App Store - 10 CanMakePayments/CanMakePayments.cs b/Samples~/Apple App Store - 10 CanMakePayments/CanMakePayments.cs index 0994ec0..ed103cb 100644 --- a/Samples~/Apple App Store - 10 CanMakePayments/CanMakePayments.cs +++ b/Samples~/Apple App Store - 10 CanMakePayments/CanMakePayments.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Apple App Store - 10 CanMakePayments/README.md b/Samples~/Apple App Store - 10 CanMakePayments/README.md index 4ca2f31..4fe8575 100644 --- a/Samples~/Apple App Store - 10 CanMakePayments/README.md +++ b/Samples~/Apple App Store - 10 CanMakePayments/README.md @@ -1,8 +1,8 @@ ## README - In-App Purchasing Sample Scenes - App Store - Can Make Payments -This sample shows how to check whether the logged-in player is permitted to purchase from the Apple App Store on this device. -This allows developer to know if they may need to alter its behavior or appearance before it can engage the player with in-app -purchasing. +This sample shows how to check whether the logged-in player is permitted to purchase from the Apple App Store on this device. +This allows developer to know if they may need to alter its behavior or appearance before it can engage the player with in-app +purchasing. ## Instructions to test this sample: @@ -11,11 +11,11 @@ purchasing. the [Apple App Store](https://docs.unity3d.com/Packages/com.unity.purchasing@3.2/manual/UnityIAPAppleConfiguration.html). 2. Build your project for `iOS`. 3. To test, click `Can Player Make Payments` and see the text output for result. NOTE: On non-Apple platforms this returns **True**. -4. To test the negative case, where the player is *restricted* from making payments: +4. To test the negative case, where the player is *restricted* from making payments: 1. Enable **Content & Privacy Restrictions** in the **Screen Time** section of your iOS device's **Settings**. 2. Choose **"Don't Allow"** for **In-app purchases** from the **iTunes & App Store Purchases** restriction setting. 3. Repeat this test. - + ## Can Make Payments -See Apple's [canMakePayments documentation](https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments/) for more information. +See Apple's [canMakePayments documentation](https://developer.apple.com/documentation/storekit/appstore/3822277-canmakepayments/) for more information. diff --git a/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/README.md b/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/README.md index 20f7163..18243ef 100644 --- a/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/README.md +++ b/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/README.md @@ -25,4 +25,3 @@ NOTE: Testing may be complicated and not convincing when using the Apple Sandbox See [Apple's documentation](https://help.apple.com/app-store-connect/#/dev75708c031) on the topic for more information. - diff --git a/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/UpgradeDowngradeSubscription.cs b/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/UpgradeDowngradeSubscription.cs index 3125de4..229e73f 100644 --- a/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/UpgradeDowngradeSubscription.cs +++ b/Samples~/Apple App Store - 12 UpgradeDowngradeSubscription/UpgradeDowngradeSubscription.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/README.md b/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/README.md index 0c8d0b8..c5241ac 100644 --- a/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/README.md +++ b/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/README.md @@ -47,4 +47,3 @@ on the topic for more information. Certain proration modes are recommended in certain scenarios. See [Google's recommended proration modes](https://developer.android.com/google/play/billing/subscriptions#proration-recommendations) for more information. - diff --git a/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/SubscriptionGroup.cs b/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/SubscriptionGroup.cs index 110c30f..d9e1076 100644 --- a/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/SubscriptionGroup.cs +++ b/Samples~/Google Play Store - 01 UpgradeDowngradeSubscription/SubscriptionGroup.cs @@ -50,7 +50,7 @@ static bool IsASubscriptionChange(string previousSubscriptionId, string newSubsc void ChangeSubscriptionTier(string currentSubscriptionId, string newSubscriptionId) { Debug.Log($"Change Subscription from {currentSubscriptionId} to {newSubscriptionId}"); - var prorationMode = (int) DetermineProrationMode(currentSubscriptionId, newSubscriptionId); + var prorationMode = (int)DetermineProrationMode(currentSubscriptionId, newSubscriptionId); var googlePlayStoreExtension = m_ExtensionsProvider.GetExtension(); googlePlayStoreExtension.UpgradeDowngradeSubscription(currentSubscriptionId, newSubscriptionId, prorationMode); diff --git a/Samples~/Google Play Store - 02 RestoringTransactions/RestoringTransactions.cs b/Samples~/Google Play Store - 02 RestoringTransactions/RestoringTransactions.cs index 14a50f5..ef0a291 100644 --- a/Samples~/Google Play Store - 02 RestoringTransactions/RestoringTransactions.cs +++ b/Samples~/Google Play Store - 02 RestoringTransactions/RestoringTransactions.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Google Play Store - 03 ConfirmingSubscriptionPriceChange/README.md b/Samples~/Google Play Store - 03 ConfirmingSubscriptionPriceChange/README.md index 532d806..ef61693 100644 --- a/Samples~/Google Play Store - 03 ConfirmingSubscriptionPriceChange/README.md +++ b/Samples~/Google Play Store - 03 ConfirmingSubscriptionPriceChange/README.md @@ -31,5 +31,3 @@ From [Google's documentation](https://developer.android.com/google/play/billing/ > > When you increase the price of a subscription, you have at least seven days to notify your existing subscribers about > the price change before Google Play can start notifying them. - - diff --git a/Samples~/Google Play Store - 04 HandlingDeferredPurchases/HandlingDeferredPurchases.cs b/Samples~/Google Play Store - 04 HandlingDeferredPurchases/HandlingDeferredPurchases.cs index 5ff42d2..26ee3c6 100644 --- a/Samples~/Google Play Store - 04 HandlingDeferredPurchases/HandlingDeferredPurchases.cs +++ b/Samples~/Google Play Store - 04 HandlingDeferredPurchases/HandlingDeferredPurchases.cs @@ -1,4 +1,4 @@ -using System; +using System; using UnityEngine; using UnityEngine.Purchasing; using UnityEngine.UI; diff --git a/Samples~/Google Play Store - 04 HandlingDeferredPurchases/README.md b/Samples~/Google Play Store - 04 HandlingDeferredPurchases/README.md index 0e44cfd..522ece0 100644 --- a/Samples~/Google Play Store - 04 HandlingDeferredPurchases/README.md +++ b/Samples~/Google Play Store - 04 HandlingDeferredPurchases/README.md @@ -28,6 +28,3 @@ From [Google's documentation](https://developer.android.com/google/play/billing/ > can then choose a physical store where they will complete the transaction and receive a code through both notification > and email. When the user arrives at the physical store, they can redeem the code with the cashier and pay with cash. > Google then notifies both you and the user that cash has been received. Your app can then grant entitlement to the user. - - - diff --git a/Samples~/Google Play Store - 05 FraudDetection/FraudDetection.cs b/Samples~/Google Play Store - 05 FraudDetection/FraudDetection.cs index ddcba07..1bcbd75 100644 --- a/Samples~/Google Play Store - 05 FraudDetection/FraudDetection.cs +++ b/Samples~/Google Play Store - 05 FraudDetection/FraudDetection.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; diff --git a/Third Party Notices.md b/Third Party Notices.md index ca73bb2..be155e9 100644 --- a/Third Party Notices.md +++ b/Third Party Notices.md @@ -29,4 +29,3 @@ ASN.1-Editor //| WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS FOR | //| A PARTICULAR PURPOSE. | //+-------------------------------------------------------------------------------+ - diff --git a/ValidationExceptions.json b/ValidationExceptions.json new file mode 100644 index 0000000..130f943 --- /dev/null +++ b/ValidationExceptions.json @@ -0,0 +1,10 @@ +{ + "ErrorExceptions": [ + { + "ValidationTest": "API Validation", + "ExceptionMessage": "Additions require a new minor or major version.", + "PackageVersion": "4.2.1" + } + ], + "WarningExceptions": [] +} diff --git a/ValidationExceptions.json.meta b/ValidationExceptions.json.meta new file mode 100644 index 0000000..1488339 --- /dev/null +++ b/ValidationExceptions.json.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: feea2df7ea874d17a985548753a89e50 +timeCreated: 1655327261 \ No newline at end of file diff --git a/package.json b/package.json index 59f23e3..5ee05e0 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "displayName": "In App Purchasing", "unity": "2020.3", "_upm": { - "changelog": "### Added\n- Support for Unity Analytics TransactionFailed event.\n- Sample showcasing how to initialize [Unity Gaming Services](https://unity.com/solutions/gaming-services) using the [Services Core API](https://docs.unity.com/ugs-overview/services-core-api.html)\n\n### Changed\n- The Analytics notice in the In-App Purchasing service window has been removed for Unity Editors 2022 and up." + "changelog": "### Fixed\n- Downgrade `com.unity.services.core` from 1.4.1 to 1.3.1 due to a new bug found in 1.4.1" }, - "version": "4.2.0-pre.2", + "version": "4.2.1", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -14,7 +14,7 @@ "com.unity.modules.jsonserialize": "1.0.0", "com.unity.modules.androidjni": "1.0.0", "com.unity.services.core": "1.3.1", - "com.unity.services.analytics": "4.0.0-pre.2" + "com.unity.services.analytics": "4.0.1" }, "keywords": [ "purchasing", @@ -24,15 +24,15 @@ "license": "Unity Companion Package License v1.0", "hideInEditor": false, "relatedPackages": { - "com.unity.purchasing.tests": "4.2.0-pre.2" + "com.unity.purchasing.tests": "4.2.1" }, "upmCi": { - "footprint": "e5c58b5ba0a54e9b6ab4dc8e4ce89ef8cd8c7680" + "footprint": "5d77653888c5b36e3c4caa909124141ea75b1c19" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "64dd87f37c44581d6f8ba38181a7e3661b01999b" + "revision": "258ef39fae29e6ea64d6813cd615b9424e3a9955" }, "samples": [ { @@ -60,6 +60,11 @@ "description": "This sample showcases how to use the cross-platform validator to do local receipt validation with the Google Play Store and Apple App Store.", "path": "Samples~/05 LocalReceiptValidation" }, + { + "displayName": "06 Initialize Unity Gaming Services", + "description": "This sample showcases how to initialize Unity Gaming Services using the Services Core API", + "path": "Samples~/06 InitializeGamingServices" + }, { "displayName": "Apple App Store - 01 Refreshing App Receipts", "description": "This sample shows how to refresh Apple App Store receipts.", From 3248461b7eb7146cf4f5070479286cfd46d14ef6 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Thu, 16 Jun 2022 00:00:00 +0000 Subject: [PATCH 04/12] com.unity.purchasing@4.3.0 ## [4.3.0] - 2022-06-16 ### Added - GooglePlay - API `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` called when Unity IAP fails to query product details. The `Action` will be called on each query product details failure with the retry count. See documentation "Store Guides" > "Google Play" for a sample usage. --- CHANGELOG.md | 4 ++ Documentation~/UnityIAPGooglePlay.md | 40 ++++++++++++++++++ .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes .../AAR/Interfaces/IGoogleProductCallback.cs | 10 +++++ .../Interfaces/IGoogleProductCallback.cs.meta | 3 ++ .../GooglePlay/AAR/QuerySkuDetailsService.cs | 16 ++++++- .../FakeGooglePlayStoreConfiguration.cs | 10 +++++ .../GooglePlay/GooglePlayConfiguration.cs | 19 +++++++-- .../GooglePlay/GooglePlayProductCallback.cs | 21 +++++++++ .../GooglePlayProductCallback.cs.meta | 3 ++ .../Interfaces/IGooglePlayConfiguration.cs | 8 ++++ .../IGooglePlayConfigurationInternal.cs | 3 ++ .../Stores/Android/GooglePlay/package.json | 7 --- .../Android/GooglePlay/package.json.meta | 7 --- Runtime/Stores/StandardPurchasingModule.cs | 18 +++++--- Runtime/Stores/Util/ExponentialRetryPolicy.cs | 7 +-- Runtime/Stores/Util/IRetryPolicy.cs | 4 +- package.json | 20 ++++++--- 18 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleProductCallback.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleProductCallback.cs.meta create mode 100644 Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs create mode 100644 Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs.meta delete mode 100644 Runtime/Stores/Android/GooglePlay/package.json delete mode 100644 Runtime/Stores/Android/GooglePlay/package.json.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b56214..6f2e9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [4.3.0] - 2022-06-16 +### Added +- GooglePlay - API `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` called when Unity IAP fails to query product details. The `Action` will be called on each query product details failure with the retry count. See documentation "Store Guides" > "Google Play" for a sample usage. + ## [4.2.1] - 2022-06-14 ### Fixed - Downgrade `com.unity.services.core` from 1.4.1 to 1.3.1 due to a new bug found in 1.4.1 diff --git a/Documentation~/UnityIAPGooglePlay.md b/Documentation~/UnityIAPGooglePlay.md index 0e42ca7..1a5303b 100644 --- a/Documentation~/UnityIAPGooglePlay.md +++ b/Documentation~/UnityIAPGooglePlay.md @@ -50,3 +50,43 @@ public class GooglePlayInitializationDisconnectListener : IStoreListener public void OnPurchaseFailed(Product i, PurchaseFailureReason p) { } } ``` + +### Listen for failed query product details + +Querying product details from the Google Play Store can fail due to certain circumstances. When this happens, we retry until successful. + +For example: a user first installs the app with the Play Store. Then the user launches the app without having Internet access. The Google Play Store will be unavailable because it requires an Internet connection which will result in failing to query product details. Restoring the Internet connection will fix the problem and the app will resume correctly. + +The `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` API can be used to listen for this scenario. The action has a parameter which contains the retry count. When this Action is triggered, the app may choose to advise the user through a user interface dialog to verify their Internet connection. + +Please refer to this usage sample: + +``` +using UnityEngine; +using UnityEngine.Purchasing; + +public class QueryProductDetailsFailedListener : IStoreListener +{ + public QueryProductDetailsFailedListener() + { + var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance()); + builder.Configure().SetQueryProductDetailsFailedListener((int retryCount) => + { + Debug.Log("Failed to query product details " + retryCount + " times."); + }); + builder.AddProduct("100_gold_coins", ProductType.Consumable); + UnityPurchasing.Initialize(this, builder); + } + + public void OnInitialized(IStoreController controller, IExtensionProvider extensions) { } + + public void OnInitializeFailed(InitializationFailureReason error) { } + + public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e) + { + return PurchaseProcessingResult.Complete; + } + + public void OnPurchaseFailed(Product i, PurchaseFailureReason p) { } +} +``` diff --git a/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing b/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing index e73c3c249ac9a416dcc7e3a37390eba9bfc99827..798d0645ee82adf5378eb6dd9a699985758e393a 100644 GIT binary patch delta 187 zcmbQxCp4i?s9_6ZLlSej%7yLCNsPUWV3u$STQ=4WID;L!1a%NS@n{|3b$D`ZyJ^?+r%Hk>-(+!w_)x@VUJHfn_b$OM47fTi89L_ z0Gp?Ll{pq-a`QE2zEqJe>nf+4ZzfN(>-HDoeYsS^kF!i*`%-I`o6HIir$~r|hv>94lJr(2?1e>!qJ> onSkuDetailsResponse) { QueryAsyncSku(new List @@ -41,7 +46,14 @@ public void QueryAsyncSku(ReadOnlyCollection products, Action public void QueryAsyncSku(ReadOnlyCollection products, Action> onSkuDetailsResponse) { - m_RetryPolicy.Invoke(retryAction => QueryAsyncSkuWithRetries(products, onSkuDetailsResponse, retryAction)); + var retryCount = 0; + + m_RetryPolicy.Invoke(retryAction => QueryAsyncSkuWithRetries(products, onSkuDetailsResponse, retryAction), OnActionRetry); + + void OnActionRetry() + { + m_GoogleProductCallback.NotifyQueryProductDetailsFailed(retryCount++); + } } void QueryAsyncSkuWithRetries(IReadOnlyCollection products, Action> onSkuDetailsResponse, Action retryQuery) diff --git a/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreConfiguration.cs b/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreConfiguration.cs index f9fe032..b225178 100644 --- a/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/FakeGooglePlayStoreConfiguration.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; namespace UnityEngine.Purchasing @@ -15,6 +17,14 @@ public class FakeGooglePlayStoreConfiguration : IGooglePlayConfiguration /// Will never be called because this is a fake. public void SetServiceDisconnectAtInitializeListener(Action action) { } + /// + /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! + /// + /// Set an optional listener for failures when querying product details. + /// + /// Will never be called because this is a fake. + public void SetQueryProductDetailsFailedListener(Action action) { } + /// /// THIS IS A FAKE, NO CODE WILL BE EXECUTED! /// diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs index f2a8836..d89b012 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; @@ -9,10 +11,11 @@ namespace UnityEngine.Purchasing /// class GooglePlayConfiguration : IGooglePlayConfiguration, IGooglePlayConfigurationInternal { - Action m_InitializationConnectionLister; + Action? m_InitializationConnectionLister; IGooglePlayStoreService m_GooglePlayStoreService; - Action m_DeferredPurchaseAction; - Action m_DeferredProrationUpgradeDowngradeSubscriptionAction; + Action? m_DeferredPurchaseAction; + Action? m_DeferredProrationUpgradeDowngradeSubscriptionAction; + Action? m_QueryProductDetailsFailedListener; bool m_FetchPurchasesAtInitialize = true; @@ -41,6 +44,16 @@ public void NotifyInitializationConnectionFailed() m_InitializationConnectionLister?.Invoke(); } + public void SetQueryProductDetailsFailedListener(Action action) + { + m_QueryProductDetailsFailedListener = action; + } + + public void NotifyQueryProductDetailsFailed(int retryCount) + { + m_QueryProductDetailsFailedListener?.Invoke(retryCount); + } + /// /// Set listener for deferred purchasing events. /// Deferred purchasing is enabled by default and cannot be changed. diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs new file mode 100644 index 0000000..6048b44 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs @@ -0,0 +1,21 @@ +#nullable enable + +using UnityEngine.Purchasing.Interfaces; + +namespace UnityEngine.Purchasing +{ + class GooglePlayProductCallback : IGoogleProductCallback + { + IGooglePlayConfigurationInternal? m_GooglePlayConfigurationInternal; + + public void SetStoreConfiguration(IGooglePlayConfigurationInternal configuration) + { + m_GooglePlayConfigurationInternal = configuration; + } + + public void NotifyQueryProductDetailsFailed(int retryCount) + { + m_GooglePlayConfigurationInternal?.NotifyQueryProductDetailsFailed(retryCount); + } + } +} diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs.meta b/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs.meta new file mode 100644 index 0000000..43ff16e --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayProductCallback.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: de57eb3908af436d93fe995bf7a90523 +timeCreated: 1655826155 \ No newline at end of file diff --git a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs index 0a3d87e..fc4c3b4 100644 --- a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfiguration.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using UnityEngine.Purchasing.Extension; @@ -21,6 +23,12 @@ public interface IGooglePlayConfiguration : IStoreConfiguration /// is interrupted by a disconnection from the Google Play Billing service. void SetServiceDisconnectAtInitializeListener(Action action); + /// + /// Set an optional listener for failures when querying product details. + /// + /// Will be called with the retry count for each failed attempt to query product details. + void SetQueryProductDetailsFailedListener(Action action); + /// /// Set listener for deferred purchasing events. /// Deferred purchasing is enabled by default and cannot be changed. diff --git a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs index c081c83..52189c0 100644 --- a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs +++ b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using UnityEngine.Purchasing.Extension; @@ -9,5 +11,6 @@ interface IGooglePlayConfigurationInternal void NotifyDeferredPurchase(IStoreCallback storeCallback, string productId, string receipt, string transactionId); void NotifyDeferredProrationUpgradeDowngradeSubscription(IStoreCallback storeCallback, string productId); bool IsFetchPurchasesAtInitializeSkipped(); + void NotifyQueryProductDetailsFailed(int retryCount); } } diff --git a/Runtime/Stores/Android/GooglePlay/package.json b/Runtime/Stores/Android/GooglePlay/package.json deleted file mode 100644 index ebe4eb6..0000000 --- a/Runtime/Stores/Android/GooglePlay/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "GooglePlay", - "version": "1.0.0", - "dependencies": { - - } -} diff --git a/Runtime/Stores/Android/GooglePlay/package.json.meta b/Runtime/Stores/Android/GooglePlay/package.json.meta deleted file mode 100644 index 45d7320..0000000 --- a/Runtime/Stores/Android/GooglePlay/package.json.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: aee966c2a3479f841ae8ef29d0cfa262 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index f56c6f7..6050416 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -25,7 +25,7 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS /// [Obsolete("Not accurate. Use Version instead.", false)] public const string k_PackageVersion = "3.0.1"; - internal readonly string k_Version = "4.2.0-pre.1"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. + internal readonly string k_Version = "4.3.0"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. /// /// The version of com.unity.purchasing installed and the app was built using. /// @@ -257,13 +257,14 @@ private IStore InstantiateAndroid() private IStore InstantiateGoogleStore() { IGooglePurchaseCallback googlePurchaseCallback = new GooglePlayPurchaseCallback(); + IGoogleProductCallback googleProductCallback = new GooglePlayProductCallback(); - var googlePlayStoreService = BuildGooglePlayStoreServiceAar(googlePurchaseCallback); + var googlePlayStoreService = BuildGooglePlayStoreServiceAar(googlePurchaseCallback, googleProductCallback); IGooglePlayStorePurchaseService googlePlayStorePurchaseService = new GooglePlayStorePurchaseService(googlePlayStoreService); IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService = new GooglePlayStoreFinishTransactionService(googlePlayStoreService); IGoogleFetchPurchases googleFetchPurchases = new GoogleFetchPurchases(googlePlayStoreService, googlePlayStoreFinishTransactionService); - var googlePlayConfiguration = BuildGooglePlayStoreConfiguration(googlePlayStoreService, googlePurchaseCallback); + var googlePlayConfiguration = BuildGooglePlayStoreConfiguration(googlePlayStoreService, googlePurchaseCallback, googleProductCallback); var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); IGooglePlayStoreRetrieveProductsService googlePlayStoreRetrieveProductsService = new GooglePlayStoreRetrieveProductsService( @@ -296,10 +297,12 @@ void BindGoogleExtension(GooglePlayStoreExtensions googlePlayStoreExtensions) BindExtension(googlePlayStoreExtensions); } - static GooglePlayConfiguration BuildGooglePlayStoreConfiguration(IGooglePlayStoreService googlePlayStoreService, IGooglePurchaseCallback googlePurchaseCallback) + static GooglePlayConfiguration BuildGooglePlayStoreConfiguration(IGooglePlayStoreService googlePlayStoreService, + IGooglePurchaseCallback googlePurchaseCallback, IGoogleProductCallback googleProductCallback) { - GooglePlayConfiguration googlePlayConfiguration = new GooglePlayConfiguration(googlePlayStoreService); + var googlePlayConfiguration = new GooglePlayConfiguration(googlePlayStoreService); googlePurchaseCallback.SetStoreConfiguration(googlePlayConfiguration); + googleProductCallback.SetStoreConfiguration(googlePlayConfiguration); return googlePlayConfiguration; } @@ -308,7 +311,8 @@ void BindGoogleConfiguration(GooglePlayConfiguration googlePlayConfiguration) BindConfiguration(googlePlayConfiguration); } - IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback googlePurchaseCallback) + IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback googlePurchaseCallback, + IGoogleProductCallback googleProductCallback) { var googleCachedQuerySkuDetailsService = new GoogleCachedQuerySkuDetailsService(); var googleLastKnownProductService = new GoogleLastKnownProductService(); @@ -317,7 +321,7 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g var googleBillingClient = new GoogleBillingClient(googlePurchaseUpdatedListener, util); var skuDetailsConverter = new SkuDetailsConverter(); var retryPolicy = new ExponentialRetryPolicy(); - var googleQuerySkuDetailsService = new QuerySkuDetailsService(googleBillingClient, googleCachedQuerySkuDetailsService, skuDetailsConverter, retryPolicy); + var googleQuerySkuDetailsService = new QuerySkuDetailsService(googleBillingClient, googleCachedQuerySkuDetailsService, skuDetailsConverter, retryPolicy, googleProductCallback); var purchaseService = new GooglePurchaseService(googleBillingClient, googlePurchaseCallback, googleQuerySkuDetailsService); var queryPurchasesService = new GoogleQueryPurchasesService(googleBillingClient, googleCachedQuerySkuDetailsService); var finishTransactionService = new GoogleFinishTransactionService(googleBillingClient, queryPurchasesService); diff --git a/Runtime/Stores/Util/ExponentialRetryPolicy.cs b/Runtime/Stores/Util/ExponentialRetryPolicy.cs index 7a137aa..925455e 100644 --- a/Runtime/Stores/Util/ExponentialRetryPolicy.cs +++ b/Runtime/Stores/Util/ExponentialRetryPolicy.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Threading.Tasks; using UnityEngine.Purchasing.Stores.Util; @@ -7,9 +9,7 @@ namespace UnityEngine.Purchasing class ExponentialRetryPolicy : IRetryPolicy { int m_BaseRetryDelay; - int m_MaxRetryDelay; - int m_ExponentialFactor; public ExponentialRetryPolicy(int baseRetryDelay = 1000, int maxRetryDelay = 30 * 1000, int exponentialFactor = 2) @@ -19,13 +19,14 @@ public ExponentialRetryPolicy(int baseRetryDelay = 1000, int maxRetryDelay = 30 m_ExponentialFactor = exponentialFactor; } - public void Invoke(Action actionToTry) + public void Invoke(Action actionToTry, Action? onRetryAction) { var currentRetryDelay = m_BaseRetryDelay; actionToTry(Retry); async void Retry() { + onRetryAction?.Invoke(); await WaitAndRetry(); } diff --git a/Runtime/Stores/Util/IRetryPolicy.cs b/Runtime/Stores/Util/IRetryPolicy.cs index 28ce0b5..b8b4db8 100644 --- a/Runtime/Stores/Util/IRetryPolicy.cs +++ b/Runtime/Stores/Util/IRetryPolicy.cs @@ -1,8 +1,10 @@ +#nullable enable + using System; namespace UnityEngine.Purchasing.Stores.Util { interface IRetryPolicy { - void Invoke(Action actionToTry); + void Invoke(Action actionToTry, Action? onRetryAction = null); } } diff --git a/package.json b/package.json index 5ee05e0..91d9349 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,19 @@ "displayName": "In App Purchasing", "unity": "2020.3", "_upm": { - "changelog": "### Fixed\n- Downgrade `com.unity.services.core` from 1.4.1 to 1.3.1 due to a new bug found in 1.4.1" + "gameService": { + "groupIndex": 4, + "groupName": "Monetize", + "configurePath": "Project/Services/In-App Purchasing", + "genericDashboardUrl": "https://unity3d.com/unity/features/iap" + }, + "supportedPlatforms": [ + "Android", + "iOS" + ], + "changelog": "### Added\n- GooglePlay - API `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` called when Unity IAP fails to query product details. The `Action` will be called on each query product details failure with the retry count. See documentation \"Store Guides\" > \"Google Play\" for a sample usage." }, - "version": "4.2.1", + "version": "4.3.0", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -24,15 +34,15 @@ "license": "Unity Companion Package License v1.0", "hideInEditor": false, "relatedPackages": { - "com.unity.purchasing.tests": "4.2.1" + "com.unity.purchasing.tests": "4.3.0" }, "upmCi": { - "footprint": "5d77653888c5b36e3c4caa909124141ea75b1c19" + "footprint": "0ce80874c9229d35d310a72c5334ca2ee6617c75" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "258ef39fae29e6ea64d6813cd615b9424e3a9955" + "revision": "22afb8218d24e44d7eaa04f57aed2bebe79b82e8" }, "samples": [ { From 7bb6ed253197166088d94f4d8fc00b95ba54d236 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Mon, 11 Jul 2022 00:00:00 +0000 Subject: [PATCH 05/12] com.unity.purchasing@4.4.0 ## [4.4.0] - 2022-07-11 ### Added - GooglePlay - Google Play Billing Library version 4.0.0. - The Multi-quantity feature is not yet supported by the IAP package and will come in a future update. **Do not enable `Multi-quantity` in the Google Play Console.** - Add support for the [IMMEDIATE_AND_CHARGE_FULL_PRICE](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode#IMMEDIATE_AND_CHARGE_FULL_PRICE) proration mode. Use `GooglePlayProrationMode.ImmediateAndChargeFullPrice` for easy access. ### Fixed - GooglePlay - Fix `IGooglePlayConfiguration.SetDeferredPurchaseListener` and `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` callbacks sometimes not being called from the main thread. - GooglePlay - When configuring `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action retryCount)`, the action will be invoked with retryCount starting at 1 instead of 0. - GooglePlay - Added a validation when upgrading/downgrading a subscription that calls `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.ProductUnavailable` when the old transaction id is empty or null. This can occur when attempting to upgrade/downgrade a subscription that the user doesn't own. --- CHANGELOG.md | 15 +++ Documentation~/StoresSupported.md | 2 +- Documentation~/UnityIAPGoogleConfiguration.md | 2 + Documentation~/UnityIAPGooglePlay.md | 2 +- .../UnityIAPInitializeUnityGamingServices.md | 6 +- Editor/UnityPurchasingEditor.cs | 2 +- LICENSE.md | 6 +- .../UnityPurchasing/Android/billing-3.0.3.aar | Bin 79094 -> 0 bytes .../UnityPurchasing/Android/billing-4.0.0.aar | Bin 0 -> 88350 bytes ...-3.0.3.aar.meta => billing-4.0.0.aar.meta} | 2 +- .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes .../Purchasing/PurchaseFailureDescription.cs | 6 +- .../Common/AAR/AndroidJavaObjectExtensions.cs | 8 +- .../AAR/GoogleBillingConnectionState.cs | 9 -- .../AAR/GoogleFinishTransactionService.cs | 49 ++++++--- .../AAR/GoogleLastKnownProductService.cs | 27 ++--- .../GooglePlay/AAR/GooglePlayStoreService.cs | 38 +++---- .../GooglePlay/AAR/GooglePurchaseService.cs | 81 +++++++++++---- .../AAR/GoogleQueryPurchasesService.cs | 46 ++++----- .../AAR/Interfaces/IGoogleBillingClient.cs | 10 +- .../IGoogleFinishTransactionService.cs | 2 +- .../IGoogleLastKnownProductService.cs | 12 +-- .../AAR/Interfaces/IGooglePlayStoreService.cs | 6 +- .../AAR/Interfaces/IGooglePurchase.cs | 24 +++++ .../AAR/Interfaces/IGooglePurchase.cs.meta | 3 + .../AAR/Interfaces/IGooglePurchaseBuilder.cs | 12 +++ .../IGooglePurchaseBuilder.cs.meta} | 2 +- .../AAR/Interfaces/IGooglePurchaseCallback.cs | 6 +- .../IGoogleQueryPurchasesService.cs | 4 +- .../GoogleAcknowledgePurchaseListener.cs | 10 +- .../GoogleConsumeResponseListener.cs | 11 +- .../GooglePurchaseUpdatedListener.cs | 94 ++++++++++-------- .../GooglePurchasesResponseListener.cs | 33 ++++++ .../GooglePurchasesResponseListener.cs.meta} | 2 +- .../AAR/Models/GoogleBillingClient.cs | 58 +++++++---- .../Models/GoogleBillingConnectionState.cs | 14 +++ .../GoogleBillingConnectionState.cs.meta | 2 +- .../GooglePlay/AAR/Models/GooglePurchase.cs | 58 +++++------ .../AAR/Models/GooglePurchaseResult.cs | 41 -------- .../GooglePlay/AAR/QuerySkuDetailsService.cs | 2 +- .../AAR/Utils/GooglePurchaseBuilder.cs | 54 ++++++++++ .../AAR/Utils/GooglePurchaseBuilder.cs.meta | 11 ++ .../AAR/Utils/GooglePurchaseHelper.cs | 21 ---- .../AAR/Utils/GoogleReceiptEncoder.cs | 13 +-- .../GooglePlay/GooglePlayConfiguration.cs | 11 +- .../GooglePlay/GooglePlayPurchaseCallback.cs | 28 ++++-- .../Android/GooglePlay/GooglePlayStore.cs | 1 - .../GooglePlay/GooglePlayStoreExtensions.cs | 41 +++----- .../IGooglePlayConfigurationInternal.cs | 7 +- .../Services/GoogleFetchPurchases.cs | 52 ++++------ ...GooglePlayStoreFinishTransactionService.cs | 44 ++++---- .../Stores/Android/GooglePlay/package.json | 7 ++ .../Android/GooglePlay/package.json.meta | 7 ++ Runtime/Stores/StandardPurchasingModule.cs | 13 ++- Runtime/Stores/SubscriptionManager.cs | 13 +-- Runtime/Stores/Util/EnumerableExtensions.cs | 40 ++++++++ .../Stores/Util/EnumerableExtensions.cs.meta | 11 ++ ValidationExceptions.json | 10 -- ValidationExceptions.json.meta | 3 - package.json | 10 +- 60 files changed, 650 insertions(+), 454 deletions(-) delete mode 100644 Plugins/UnityPurchasing/Android/billing-3.0.3.aar create mode 100644 Plugins/UnityPurchasing/Android/billing-4.0.0.aar rename Plugins/UnityPurchasing/Android/{billing-3.0.3.aar.meta => billing-4.0.0.aar.meta} (93%) delete mode 100644 Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs.meta create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs rename Runtime/Stores/Android/GooglePlay/AAR/{Models/GooglePurchaseResult.cs.meta => Interfaces/IGooglePurchaseBuilder.cs.meta} (83%) create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchasesResponseListener.cs rename Runtime/Stores/Android/GooglePlay/AAR/{Utils/GooglePurchaseHelper.cs.meta => Listeners/GooglePurchasesResponseListener.cs.meta} (83%) create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs rename Runtime/Stores/Android/GooglePlay/AAR/{ => Models}/GoogleBillingConnectionState.cs.meta (83%) delete mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs create mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs.meta delete mode 100644 Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs create mode 100644 Runtime/Stores/Android/GooglePlay/package.json create mode 100644 Runtime/Stores/Android/GooglePlay/package.json.meta create mode 100644 Runtime/Stores/Util/EnumerableExtensions.cs create mode 100644 Runtime/Stores/Util/EnumerableExtensions.cs.meta delete mode 100644 ValidationExceptions.json delete mode 100644 ValidationExceptions.json.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2e9f3..de197b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [4.4.0] - 2022-07-11 +### Added +- GooglePlay - Google Play Billing Library version 4.0.0. + - The Multi-quantity feature is not yet supported by the IAP package and will come in a future update. **Do not enable `Multi-quantity` in the Google Play Console.** + - Add support for + the [IMMEDIATE_AND_CHARGE_FULL_PRICE](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode#IMMEDIATE_AND_CHARGE_FULL_PRICE) + proration mode. Use `GooglePlayProrationMode.ImmediateAndChargeFullPrice` for easy access. + +### Fixed +- GooglePlay - Fix `IGooglePlayConfiguration.SetDeferredPurchaseListener` + and `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` callbacks sometimes not being + called from the main thread. +- GooglePlay - When configuring `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action retryCount)`, the action will be invoked with retryCount starting at 1 instead of 0. +- GooglePlay - Added a validation when upgrading/downgrading a subscription that calls `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.ProductUnavailable` when the old transaction id is empty or null. This can occur when attempting to upgrade/downgrade a subscription that the user doesn't own. + ## [4.3.0] - 2022-06-16 ### Added - GooglePlay - API `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` called when Unity IAP fails to query product details. The `Action` will be called on each query product details failure with the retry count. See documentation "Store Guides" > "Google Play" for a sample usage. diff --git a/Documentation~/StoresSupported.md b/Documentation~/StoresSupported.md index a7cb1c2..15c5c32 100644 --- a/Documentation~/StoresSupported.md +++ b/Documentation~/StoresSupported.md @@ -5,7 +5,7 @@ The following is the full list of stores supported by the In-App Purchasing pack |Store Name|Platform|Version|Website| |---|---|---|---| -|Google Billing|Android|3.0.3|[Google Release Notes](https://developer.android.com/google/play/billing/release-notes)| +|Google Billing|Android|4.0.0|[Google Release Notes](https://developer.android.com/google/play/billing/release-notes)| |Amazon Appstore|Android|2.0.76|[Amazon SDK](https://developer.amazon.com/docs/in-app-purchasing/iap-get-started.html#download-the-iap-sdk)| |Samsung|Android|Removed use [UDP](https://unity.com/products/unity-distribution-portal) instead| [UDP](https://unity.com/products/unity-distribution-portal)| |Unity Distribution Portal|Android|2.0.0 and higher|[UDP](https://unity.com/products/unity-distribution-portal)| diff --git a/Documentation~/UnityIAPGoogleConfiguration.md b/Documentation~/UnityIAPGoogleConfiguration.md index 385d478..97a6dd9 100644 --- a/Documentation~/UnityIAPGoogleConfiguration.md +++ b/Documentation~/UnityIAPGoogleConfiguration.md @@ -56,6 +56,8 @@ Now that you have uploaded our first binary, you can add the IAP products. ![50goldcoins](images/IAPGoogleImage5.png) +**WARNING:** multi-quantity is not supported yet and should not be enabled. + ### Test IAP Add your testers to License Testing. diff --git a/Documentation~/UnityIAPGooglePlay.md b/Documentation~/UnityIAPGooglePlay.md index 1a5303b..c2b320c 100644 --- a/Documentation~/UnityIAPGooglePlay.md +++ b/Documentation~/UnityIAPGooglePlay.md @@ -3,7 +3,7 @@ Consumables ----------- -Unity IAP uses V3 of Google's Billing API, which features the concept of consumable products and explicit consumption API calls. +Unity IAP uses V4 of Google's Billing API, which features the concept of consumable products and explicit consumption API calls. When you create consumable products in the Google Publisher dashboard, set them to be 'Managed' products. Unity IAP will take care of consuming them after your application has confirmed a purchase. diff --git a/Documentation~/UnityIAPInitializeUnityGamingServices.md b/Documentation~/UnityIAPInitializeUnityGamingServices.md index 5ca0091..349f99e 100644 --- a/Documentation~/UnityIAPInitializeUnityGamingServices.md +++ b/Documentation~/UnityIAPInitializeUnityGamingServices.md @@ -31,12 +31,12 @@ public class InitializeUnityServices : MonoBehaviour } ``` -### Automatic initialization +### Automatic initialization for Codeless IAP -Instead, you may enable **Unity Gaming Services** automatic initialization by checking the **Automatically initialize Unity Gaming Services** checkbox at the bottom of the **IAP Catalog** window. +If you are using the Codeless IAP, you may instead enable **Unity Gaming Services** automatic initialization by checking the **Automatically initialize Unity Gaming Services** checkbox at the bottom of the **IAP Catalog** window. This ensures that **Unity Gaming Services** initializes immediately when the application starts. ![Enabling auto-initialization for the Unity Gaming Services through the **IAP Catalog** GUI](images/AutoInitializeUGS.png) -To use this feature **Automatically initialize UnityPurchasing (recommended)** must be enabled. +To use this feature **Automatically initialize UnityPurchasing (recommended)** must be enabled. If you do not see these checkboxes inside the **IAP Catalog**, it may be because you have not yet added products in the catalog window. This initializes **Unity Gaming Services** with the default `production` environment. This way of initializing **Unity Gaming Services** might not be compatible with all other services as they might require special initialization options. diff --git a/Editor/UnityPurchasingEditor.cs b/Editor/UnityPurchasingEditor.cs index 08a9ba6..7030ad3 100644 --- a/Editor/UnityPurchasingEditor.cs +++ b/Editor/UnityPurchasingEditor.cs @@ -125,7 +125,7 @@ internal static bool DoesPrevModePathExist() // Notice: Multiple files per target supported. While Key must be unique, Value can be duplicated! private static Dictionary StoreSpecificFiles = new Dictionary() { - {"billing-3.0.3.aar", AppStore.GooglePlay}, + {"billing-4.0.0.aar", AppStore.GooglePlay}, {"AmazonAppStore.aar", AppStore.AmazonAppStore} }; private static Dictionary UdpSpecificFiles = new Dictionary() { diff --git a/LICENSE.md b/LICENSE.md index 157af91..eadadca 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,12 +1,12 @@ -com.unity.purchasing copyright © 2021 Unity Technologies. +com.unity.purchasing copyright © 2022 Unity Technologies. This software is subject to, and made available under, the terms of service for IAP Service (see https://unity3d.com/legal/one-operate-services-terms-of-service), and is an “Operate Service” as defined therein. Your use of the Services constitutes your acceptance of such terms. Unless expressly provided otherwise, the software under this license is made available strictly on an “AS IS” BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the terms of service for details on these and other terms and conditions. ***************************************************************** -Google Play Billing Library v3.0.3 © 2021 Google, LLC +Google Play Billing Library v4.0.0 © 2021 Google, LLC -Google Play Billing Library v3.0.3 is made available pursuant to the Android Software Development Kit License linked to immediately below: +Google Play Billing Library v4.0.0 is made available pursuant to the Android Software Development Kit License linked to immediately below: [Android Software Development Kit License](https://developer.android.com/studio/terms.html) diff --git a/Plugins/UnityPurchasing/Android/billing-3.0.3.aar b/Plugins/UnityPurchasing/Android/billing-3.0.3.aar deleted file mode 100644 index 8ac8188420b0ebfee133790d3057163386f495df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 79094 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

H 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 Hyny@2`7dwvYsFs!! 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


j<@bjW?YzZAf^@^dRjS8xc%3JEk?>y-Vc2 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 z%0D6gUZHVAIlGs?SXIKcD!G^t|C*vS_qs)DB$U!=9leO{=kjcO-n{lL#V*bEs7o|jc-~$?O z(JLbWttPdE)}Ca>wZu0l?P1aC=^@3+JXNPr7F_wKmyM^AgZ-D+Ejt2*w#wq-R z@r(aj5OAOmQJ`Oz&YFNa1i07>0)IAZ=<3 z8g$jpAC?dS{K+#lk~mpZrEjz<&k)b7gxz6ZogI~hQoe7bU;5g4>OM~EeZGA_`BUOB zMUym3t1BvXkd+R=Xeb!7W-A-lU>i_UhsDJf;etfxJE^Uv4MY}|41SBy$zcI+!$QaQ zq9=J&z7W_cXW5!O8^@&_ZFQ%sEa${aae`E9r`v`dEF3XFzeo>fMPVl?oYc~9+gj!p zvVWF_*GCw%Hu@!_BYQjc)h0tJS*;-_0idBIMOedzuyg)O`E34Tz#7FC8fFHUQ0aQ9 z?`|)8gt3B3N~dyrVVUvxr0W~;EwO4r;3K|YW>;Uo5>U0OtbuR2ow`n^x z-0JwDFC-3A)4TE`4bZCmW1DYF{+oDE{!A+}?Fp^z@sQQ(Ek5NFcSg9APwuq2#55_Z z3aQ2|>YzYMLc$Ck%J6z@JCC-yYqCPjRc(k~*j%fDpmP!J3tM+e4g1Bp)s?w5M>Vvr(?bx03kV3XJDuRtptIvh1b57B@G**qeY zbDip|K(t$edjJt8f9;{6$wr0l-r!pZ<`g%!b>Qe zm76}Z{8=FpW{hKiDE|4Kd4b4wNQ_lpNW+l8*ArHdnKJmJePTMI*zwBc0jJP%xH?n; z90;J+tV=`hpE>qOL$_ z#sMdG%NLm;%)moeKN8Zl?gnD2N^jm=3H$fAVz zP_)jl7xi~+{C@N-NE!OqBdkBz^it7eX%|Hv(-CGR`RRq53Q;zWL11RPc4u`u!hD@C z^3!p69|mOP{MoO+y@2KDF*jW zN^mxeA%U3NmQObLp6v+tl9Y)L9?* zHvwtFF={d1u$-EE{NgmlaCl}wqd#}k6-BK|)&ZL%xaLy6ziLaGoRAM^bH5%klL6wV zi||CLYR|*igyxCCEZ?bql1E24D4Hfg3Ico4e8O_+J? znQ_A2u94nL+3VnvoD!)=!D@%bt;tumZ~>Qgs8-<&qH+f3XpHwS&uSC(4JqoLcW;lH z^-`}aoWbVPhxxNV8nxKAsz>JgR zBjKb5zt2b_i$g*OlhP0xIFNvoL{QZ5ZupVOcy@&8A_u}!?FPcpspQ-jVA!Raq&F-y z{Kz~Ir1!izl=$2{nhL^E*EopaTF)&1D(`2_Dhl?-B};VzXs)6jB*T}gU&8i1ZJGvs*sJQoZBNEESt zxM)6+{<;~Cw~Az`z+$5(5XZI%J@`b5$bXC-KqPM}Pn~U$*(p}=D}6>|gpVP_ZN#c5 z;|FXE!iCS$Yj{w?Q_4_=B%>&z7iA^SC*EcyD=Drjq8B?CITzDNc<4&1WYU`um{1(B z05ABcrs@O1z@44>#^kTU(=*9e7w6jJe@?PD=mDX)&g#K+08&0k$~~DIxb2d|w3mn*2aXl@O5 zz_IQ@Rg%vzWyw5Qt|`PnH&WYhyA>k1F)Qwn#^QCEuI_XJ_*NlSbZ8ZIg_H3#Q|=$0FVMZ5QfoE=jkN9}lS@Wl5#28osB2Gx$m8$Xlbgc&_g4uDL8!OsBU z?+bOZNamC#1)-bg#VD(69?RHE)Cih#@U1O8uGALGdFwpz-xg6WDpKd%<&OX?K-0fQ zrDWPHo*4-ya+dng4nP$~stW9Z8Q87r0Njb$1Nmw7e&ZDm-G$jA3OTi&owBOuo>zlS zcAiSq;+V~AQuwLJdz!&iZo->7<5R)pYY%wZ$k&03c9D)x)mF3zpPq4rO$^p1s>_L{ zo{?(?`PD2u!!w`1#ZVpIVG5EVs>y6Sp0PaFgjyvgt za#d0HO>IB`F0YoCxxrFghjKKBxMcHujb3)#|yQ8&>NwNW?K z5`4XF^uwuoZN$T>dTrFhi@II-!-)EwI{R=3=MrtbZH!Ci!o69Tt2NO(r2L}nk-h-c z9VZ+9RYf}Rx3rq+8jfquNycD~+?-zQmE3jE!BajsexiHzKpe?az2m2LAU^#gf5JBT z#0?a(xAN|UEybX_A%~KII|n0=qJcXNBacFkkeodbX;)LkkU|`&xuY5=mjXpa)}|Gk zD`&5_tAd;+KoOAam?eQ@rb6mRJ*X71lOD;eBMX>O!K@((twak=Lms3)5b4R-uJt1_ zJK%~yg_H-*NSUiLl@&Z|2{$Uo&j7l4f*P zI&;?`z3OzJUHy!C^0yrvbw)WnM)#Ize5RH8L|}R5pI`VCLizkA^LmwhLT_DkvtNZR zLlb1$f=z1;rm2Cky3E$p$Y*UBbga+m+<^2l%@s8zJ&Mj2V0$9&2;X zYi)*Kkme(-N1AIu{tD}J{_Wgw%ZU9I#%p=z-sgt;Wd(nE^kc~PH^SE_wgrlP~-8!KpVnlwIo~{AZuP=*e0f3 zq)yqc$GILCfA(XYrKIUHb?alA-7AXu1*A-Q>I< zTwVEoVF-a8v|p+E5vfowT}_n6qVo&TTxGRxZ(Lk5K9ftWf)u^8fO1eybQYQaqq3O1 z>pc9}^!tn7S$hY`jE$Nb-E@0~?oMj>m%%4SfJivNa4;i3lY`Nk%n|Cagm$J~ROINF z6Kx{ID3Sly#QJqcZ9TJN8|Xuy6htDi@s-r@BtE5VgI)En#2g_Xc=v=q>}`iqm47Ax z)P9lPrT0@O9$r__o@B^K%eJZ%_opVZj`8R$IKiTR>Neeq(AS1Az+#og7700u$)R<1kJyXrB{vv1%Wu~G)X z4E?iaPGK!pB?fmMjRROUWDT`&)J9Cv1e?`^bk~WMbWUrk%7Ig^Wdmm~=>sM+Giad# z<0wpHqf!tCliL+x4l$M+yD7!4irqH?VY1#8$8>rd#+dg^j^Ysve0^aCU2)$Yj2g<# zxoV4wf}AByewb-u*8|5G048coR#xAv&Yqvw(g$`$TiV+GFV^0HI}>c{8cijsif!Ar zlZtKIww+Y;#I`H8ZJQOF728g3x=;7}jqi>-&OPJp^Aq--bIrZhUTTjWLk%MsXUZT? z2jrOnI{KS13^E;@>HLM)pm%|7a6~kRk(prXsRi3RPoSWdHK|f}JbY?uI)qRvU9*ks zhZ@?EI?Pj*cPNylY_Rg8=I{j}v)j`4D9p5~mP+80Fmfqfk24sP3ak#yVCf^A%PZ*| zaHNydOA{KD%iZO@q>7%qLa>eUcwHbRPww0WK_Q_*aP)-BnY1`CwI&)yqfye?7%mv4 z@FwOKlmrPPlg^6XpDKmzx_BA!=)vzC=QQmSt`}+*s3=J?7rZ$pwy10x`YQm$aV*d_ z6DMKPMrO@&AVruk85WjXPKGR8o*XQxY55Ft^c3Je@tCmjhyFkp22S~X?EDX+^^Yj) z@JF~I_xm~qdL&OsfWQj^YLYfOl)=23-?$q~eNUm8zaMcoQc>GA8F7{bDRCme40~xe z+OXgTLE2IZUub8JzDv3J-S~k-?Q&c zTfk#@b=%LFWO{dSLY{4)+xhWN+wWb?lgT<-t2;7+L4aQv49 z*Y6UeqC(3>eBcvR9~>@BB*sTzrrWup%^s3cG!i>8WhCE<-mri%l0m=Y9C) zZTy?HZ#L+|m3v}S@jg>``Im{taQV(27I4h)1in^Xbu?4=o(q1C)LI1u;$jY2a#q=7 zT$DyZq}w@HzD{l4?oB4BP-3Pr-8)K~1alD_kilzpugN+6p|!A2>FTi!x<}&$hV)lKlF;XNpn~P%|(7IG~l26 zN{W6h<2K%n_7xR{RPIe;$o@vqfadg7za-D<;S9>t&WsE9rKRz9V9b6KK_8nztr`J} z^&Pap_uxULvPEL>19>naZBKM}_G`H->?a z@ELO2o`e+Jj$f!aH9PhxtQYL}q?|?SKq7=q3g4uh`C=aOFVLnFd2e7WFH(MED@HYw z6oVRIvITUqT#kXAGON%te~zA&q9c~PxLal^cY0@@LexUp=I@cinzJqqf7XNS^QP9F zeE|ZB{^$S1_Wz<#kX(;M-zSA;3oAn9;|VwJ5`>0PZ;ir{LFpiIAv}S@6QRfpnlCwW0M0BmpX;-}If*l1d>d;c@ zFBHt$Ku9={GRfFt%svq3Wt|lYhhY1yaJ%vlQTVcT#|erRt>bi&p1X3N-AnFfBkJ?r zF3T3>a*jQ96n*|9=e7ZPq&I5uyrFj*G?DJ`kht#V%5OUhG!-HGDLf@q?b;ch$)5_T zz72Ba4e9o*)RNV94X}z|9LSkY+D=7Wg@i2U?@n<_xpfEzXRqgiCY-X_vyUZ#ty!#A ztg+Ns9iZ(wQWK1M@Aqf3{4=Q}JL4w=5C0ej!~cOn2KW<(JpxGH%LY4rFY1soiqJ|Y z5I=&npMoYLNV}y8fP{pkZfFRvG$_9AXvRi>m=tME_^juuql}F#U)Kg7kk)R>mVY{f zu?Ibi!}k{jijrV0@f@U4(m2}Wc2EI1oEnb2up`q#2h|3I1z3Y|EjcFxdlnnuJ^tB^@-dhQ_uqlxuR_Y8*ojYVK@OU#Uod+sJJ%AM z7q(BNSW2|fuo@69m;q(0B#oC$VNCtB2Q$2hpg%VKAW@(aY*C-%Z3a0`dHHns0yPg{ z^d;6)ppj0Nhq>q1tB}TO#E!J2P<84I;i3y#k|z0E>Lc=onh-m}IRoxpC?jfGh7(!5 zE;T*O=(QrKO5}_n_8y=k3#Pv^XsJ39NtY-hRqp@ZT0f9~WX53BQkhj8#W|%Ma(U<1 zDOYh&rgTGx^Y3<-@1U=*B`1+PUAi&fPEzzxhC(r4*=W^kw%2*aqI=`h*|2W)={j(I zqZMST7A8HCH$SGIs|In!8ddE(lC|3I-(x+t!~4;}M&HYu#!k-FOAuuT?e*uhlwhu> zV}#Ee9{b0)Z2bS$9E+2@PE^|dq1@&)WT%4kB$qG&=h1QUkTNF^ivLS<#>HyDK}_r& zY#k=I_kZ7zLC?pgKvf3fI#luH zlG74Q*){T_Xr!z4&zNnz(nyjsX1Fk^=cK?=bQ+RR21$f$Go~LF^4g`LZz4Kn{ zlkCmwtbK!dSR0<7WN$CRzs=UnIbixE`xCMznWlQ)3SUB&JflYbZbRz?el@=_)C#G@ z%#)d33z?6>fT2e$)3Rzktij+X*>_1_`8$^WMHiuu3YdbR1b?D7BKdi9^2 z$Mo;-iB$z8es~_~A3;EfxxkPX?fi%asL1l6g#NBbW59F;NyacE&E?c>bLW7W_9u*I z6a|tn-p4OKv7Ku7)L7Ku=)Vb4H6Mx;c=+hr%awX;0%$Qb@cf>#1j1 z!;&bhbWL!HClk%l!qcOOf@(nQF_PQpJ&$ZBT!}->`V|rdZQW6Rx!xtQ@Ot- zLr>41V`{;3E|LS)iwbbkoEAh^0fiZ2pl<1zRl9YHf78}fdyXU7=!8yX(#_eHlT!$| ztWT0fXZ_HihFV8FWjtv+Rc|sO7{WM8q{)Vw4}K?y-38C_d_l*IvUh<#+H~7(>m|aL zDr7xm&*D{XluRAs(Hnd4{krS z;USY!PS!H9yfi9JVFaGanr{!Ff8Q)^HrLQv0}>GoN8GriT^VWz)CI1#xzpT*Rp8bj zzL_OxF1o86!lF&v`N2CDd{p#sK|w>6EOvm84{%b$#k85>5;H!jdxi z{q*5Qv0dL0`ju_>)B|Ra;loP`2Tuyspr%&3XSU}NaK+CG`EquNWWik@yEv8#(VK6a z0fhksPNp7aDB4GK>@kz&xrWB^@z2iN_^pCPRO7_CJ4QPlmh5R$UV|k9CNLxZkn9Oh zVOF5N1ovO}`vbA2`|4;r191hU1LeF=Ij+B~3v# zx+SVm|14n0bS~GlTIe!UFfoIhIWO-^o@}o zy^PjoH=RM-b6rA0vQ=Qb^C_BH-h}uH>@hE5s^ZQ#Z8?baY}WIFV~WE&%k$-K^?(kj z@h3xh+D45@bJE3f(n2lHkjrYJp(ERL1d`^-9Va5X;Q25+6Z4SW>dMUm0viYf1VE_D zl%D#K@2T+Mo4-2Bm9!iL0u8{KN2Wk}34{Z6p#)h(EHNnX7IZL1e<^7fy+LvF6kU76 zI!jHYn`ZrdM%1p0QNVr$v68+$f^8#;g?CN_bvI>F50V2g2eR4_5DX3DZuX0=Op!g` zWhAJN`Z{F-Ka?XU1JSvC^3o+?UrU9P_3mU8P8TW{HhjC05hE!WjlVnDUa;F1U>CmS z8-N$Q9eW14mRvnrEzUa!UXW$KNQh_gTeN~jR|(GL+9mNw&T-h#=%VVVeawMZS|L# z=|QK4${2KNt0>J@LUx{%xAcszUPQS<`VkFX6wJVtR+n|Z0ApWaZkrNkA{J{^4lS|Z|!p?Cz@CfsF^ zTNEY-))m`uL!+21IV}W$mRy#$`o2D4^Kh3IB3tlDHI5&K zs_qHr5B8lr6+%-00|8O~+s3Z{gnj>CiShs62kzhJ#s3!%95V+R`+w(w`ztM85hp46 z={E&URm|7WaoxR@YVa4MaG_|IgeV`D7Aqa`W&+YUO^i76rhN;@bD{odO>fWpYClWM z93Hra-IqjE0k}fAFZLS%LU6qjA6xaMZtOnM-$QWQq)qb6 zB*x;mS_wMBtH%PlnZkKYuFxEtnz*>vD3ZcR1YXyS6>D>`&28+buf{K{B*Cc& zaL2O$05UzmMBeGs>$>{KP49nI0}Lo?OJd2x^UU_!AT7FB&%#w`RM3g9n84f>M229J zgMTUT_wHjuH5cxf8tQ z?pXIIKRRi1E4>JFqys(wFm$lLob{TP$qBcDEYWI5XGy^@>Gmwmu=H|64_uyxbpsdp zo?pmZCAcshr)f;!^EyUnX))fC7>g4!Rhhv4sDz3zIkIHrSYC09-6eT2nA;9vu-A@s zTS{QM+Z6aa&H$HN7Aqb+Z1_j{2j6g~%86wA=QJ8=en5M6&tA*Ay^=KA`7hf#^~<~UcT7Y&qA9xkl;a&p zzivYS*@#BJ(huylkDG9}orRS2{qv)RQijJk`xEokfBSFspJ4u1I&vWGa-tjyJA9)@CW-0@)123wY;`Yw)79P z3d`Co!rVQ4+(4@V!UCgLrHyuDokgQA;^q}=VR?y8AewUt>sA&ZLe-N4LDda*RQLKv zTAY4<==!9ktiT1=4tT|mURDWjaQF}4a)*}W!O@d4io~(z%{mE z|APfAWp`pP`HHSw?B2mCxuIH}T`5#$o|wv83_RBq9)i?KjBu`%kRl=%(Fr_o%4Z^rRQ`?jnm?LBiwKt?OKP(qM7Rp5w;J z8qjJs41|jU*QBTWok3=T$A9*;lXmylgnptM|BunN{<~^fTN0QbnJ0ds(7M2QQ$>T$ zVvqbT0U;IvR3LErrP+p&^?bB(HK3~$AIjqp>^3jAtqg(0=vN=T>v@Lb{+j2(-PzOE zZdz818Nm8ZZz)$}`AwRVga(YD_^d;VndHV%Ju^K(O>qsfGH`zDprO#b8B+kdjDNwg zUw(XVOY;eTh3$2z?s$~A4oWS;09O*)5NsyyoIucXi75c)J-JG)Y-m5BxhV|y3I*sSXSP@r$I1+{o6(rCj znDamkV?5@fC~!>BCDB9qujX2D>c`OOKE@Z5jd4$|(?15e{$Rpp#BO+ zhS!4C?%~1ExDV8nCq|+F=54gOl2pAn-D)qAg+(dGbV{~tJ{+|7Jd^p2e{Hcgs2XRR zLd`%-G2$^YdumtM$qHc>tRw{lH|RoS~S<7qeL>9ZI%pR)rBiTkPn`O&>^D z1I;@e`C2VVWS&QWq&Fg0#k-pm=M6#AH86+CK{2gvv|mDTTsd2=GHX1#7Y-4C2j>A7h(0+dB-*XC zvV?mt^`}0}!JCc_JFgT`LVThh&vcyXIIv4;dPr6UB4gJZ#*m3VX*}ZW{cTN9P|XEE zPCB#ipnJ4CB-ewksq`swELp|zSwGhR6yf*BKur6Q5wl^hqBmFasV)}Gtm&KkAmGC=1C>S;3Pk*G2Gxb3!ZSH#;5e=oAHP=2)gyxWeD_tFuR(%H z6j9pl*5|3KyD`~6)BJe$c81xaX#}Uzu{#Iv!uVEt#GJ4&YU{2c&gUSc$nAS25b8oyfJ1lLVS_%{oHnh&T+bXsDs}JSU%k7rzW;`=AW4Wf zx#~g3&kBBXE`8vE>@c%6yqmg~eqVVk6c9kF|G>9?Y`QLp0a3t}ZB&Hwh=b8rKqp8W zd+p{WutD3>#Bq^IPtOtw@morY2+K2Cn%f`4Q&wtRkM$lEVlu@GhMH{FmsD%%L!ooR zNocTxBKr3e@>DJPPqE=sDqUf)KuJ$41iX}C2HhNOPSz`6D)s#8QHurtLgFQ@dPZuE zr?SIyJJpKLGAq?ah-Hsy7#{ch`g#|&apXtXm|BOLdmv2}-kNex19j*3^hbF;SX9%h z8ygt&`yT_>uH!eOHyZfw)NkE4NYA69)gfTs9H3SXe$cA|Ph<!wTTyr`pN zN4!a$1L%_*Qfbz&e>zb?N>#@#pKKWT$7*cL}UVghjg z?(PS5Z6l*Kmi06J59Oa<|YsOZTpP39BeqIMo%@l0u{02V>iBa(zH)b zC*!QfN{Cmjs?6L0YeMJa2uEXv=-v0nS0`S!-gnG8uKEFp2_E9D0a;|&dlqVh#Cc-y z7OuXIx>Jmi=KGC&a%i&>!FJbOqlH|M%H7Ii$usdiDEtYi=Phz&4&kEIiJIS3Vt;)~ z@fR2>b7DPAF~R5#Y(qefXf;}faNoWY2$8^Dwm|0rV|Ao|6wN8T0!-H~qQtn~1V*_o z^ubNB4qsiVO5#tx$L;E~4Y9p6mZTV9Iur;dtTK?LTGn?)hElps5W?O%@!KKZaXfhz#24aM899ukzfp=8 zB$@2>@)1+NWq%~m=O$M#o;^sE-Gbz3b`e}R0AYD%n03^c=7s%<8c3*Uygt#>O&M za1AeGO*~RDS;-bA zbln9RX^~mL>LID)D%nPdz6Ot`atp!eJ;7>N?4|CByu(`zY|!`8rg^wnb3n}6QAcrf zlOeAd!imy=N=Qmqr%Nzl%a#(zl-B@BSU^c|4Ol-uE?aQ0OqE*>Z6UkP3(LRpcc=C{ zfciQ#LGewEvF8;43Cr-tx=DL!lSUIxZMnhro=#@hF@Pk6P4J;5W#FXum^1-NUDR&a z{Dlq)Yof|+D|2VWbsR0$)2_2Us>V3+PJ>s98bSv)e30W0Y9t=^>kR%^jMnjQ-*{D~ z_d7ffh!{c`>ZMO4-8Xp(k?eLvaW6uP>C25I<>Z74iVC>b#L&UR0K6?xj3NmGW_iOU zBNrE!sf?Dhv6ES*AJWu_N!V3}8l`)lwZNqoueNDe{o0M%m3zjuBgkI>oTyByI%rzTqdzs>?TsR5jW}w^4tM>O(K{+NJvSd*nL}rEnqRG9U>C5{+MF8D$y}Pz&+X zfBcynT?fCwcKC$x`5#;O-&-yfMr45b;k~~e4iiGtHdldxpkqX4^#|!0aKp1$G_y_r z8mt+DW+J^-#f<#*Cytge{cduww>fQPt=Ai69&cAq_po|Ml7R)Sj)oBHb~y9Z7Nd=c zo#m?%7E_2#qZUm(AVT!%J2GmG)5#0s6%;2KFi0XkwWgg5xh9ezAc1ymG~h3ZA2qVx zO+F+V*J2IaLmhna6`G<=MnQrl1)9Xr=j0XbJOUi6WGa=TrL`>vSX0}ubgi`x0;Z=#jJd6nlUp4a~vPv^fBTVd5g zksn!`D-_oeF+IL0N2RI>c zm%S`xkrlD_NO z%>W1_~nwtRV&vO9RCm2ead+#Yo*H{t6IQZ z5Xf`do6A>0KbD|)MQyLlgAl&zye*Jr*ji)fxd5>pgUhArbCO@ff@_Et;~4BQRr>UJ zAi#<@Puu_#Wkd~WMWxy;QfyEDIRhbQ8=3 zI?bM|OEq9?)|Uff6pCFGAuF}Q?}AvcH!$%1m-Su878;VI8gT{D ze}M~x4Cv{RMgZ;KhVN4JP7;HtEq*tcix*tV#@~OX%1d;cp&6+DVT698MvVQ@1O*}) zm|x+e3O^b@{!~)Ln3jA1Y5sIbvYCofE>aW0B5>GiV?ORCOzv}bhq=~rMph-KYqf1~ALa%9B_A;4WS*7X|lljDlhj@!pT|p*Dyc0 zOI~hf&(B@*mdzORxooyL3}h)|SBVu7@$cZr_5{0t!hWOj%&SGs0r=OyWri_h6g6wT z36;6{do}p@CmIIo^5%2nc}WH^ggunv#oEp`Ue~h@pa9_1it>?33p5PE9H6V9*dUvD z^HkmdPR?Pg&_e`z(5z=rdVF?h(h^WYjDnArbG0ENOoZNKnCt(V?#W9a*hv0KJdJ;> zByNA_HvSmyiJUQ8$N=+g4J!FN6ixC@^V}-1EOJgMe;; zIe)yTt^dlmCd=%J-Dn)=_2X;|2k+ry_MjUp5+_LH#f>%W^?Ep{@f4n%if)Wk%r=uX zt;!zLf*s7I1BY72D?e2`O)@V>+9(tDvh2;>Jbt&CZ-fFfHDL$}r#1OSziy0Xx}8>e zw1YjFex4Q%bt%mG|*h2Ex>o7Q07+8x88=24;xZRf;0G{v&N_+rJ zjl;a$dv5+2(a$@t-L8CUmBPQBUHG5S#r^Ny=_fKs{K)Tx-_eo;zMu%rh5fLBI#c22 zKM9gEv{DHDLc}*wkCV(WyKLeboei?YPe_<-<>I z0Yo270vIXPY-*`=uPk3iDM^@ek8`(eUwUJ{!ZD08IiA=Ulm?Xncah)`8$`G?|>o_w4};WdHrs)0bvvbz)!H{e1HYuHGI@unb*(Tp!i^20#M)IGW|J ziZV-*B&O)gaiL}u3{23CP#Jm$1oV$eDh+AjgSXUEG-4)4U50B4r6FMN-P7W@I>V1})UcLu#uJ&cV>8|WZ49qDm;`cSY-0B!p@hOO z=;yye#b?;1Mz`vl@yYXX zZl=12KXvY0Kd~A+kVrO#AGy@;fT|)9e0@Q3^qVG+E9LXH)SA66;DR>hfOGUm=b^%j|-$>{01q9LeH0zX6Os zy&0?QD?YuabgR2;e%NG`%%~>Tb6ZMs-Cwax?P(Wj5!#VVn(W+ry^=2%co^>Ttj#j+ zd6v|==P4`oV&!xcMMzEpCq4ws)2W=WR#}s(wFZ!F?1tL5zh8TKjt-L@4JZE^d$mU> z<{0Sfpf2OftJk zDEwY6XP4n+1w;fF_c7~#gMGlz$9CLa#)O!+{`w=leV=c8g=+)JriPkH=0-!mqn~}K zIq;->uM_H2)2N(9^wbH ze1&$UhI&ny60365cCiVGNCvnN1GyI5XR3(uA0mc@;@RppQLS@I0^vn-q%`F#dML#_ zFa)eipp;Nzh-zF!4BYXd^2t)ns4DTS>?R&v_S4^reoS!F$Vokbhy3_4G@FK$Q}Bw# zbY&-HZHD8jL7$l)1DIbsJ`XK>L3GN&bQxl*N%8!?c_vdzz`~giNM72W5xziB+v89d z`BJi)krM6>i>%+-oe_^*qraUGjyYsijGD4Ok9~S-2FLoq=4@rrxC_03Ek6rP1+~`R z>P!iVnV}ZJgj4LFZno7E%CMX<_YLMNciB1ujkQ|(XP_y#w&O@uqGS@&yN*NSLOp-B zan|q9mxfYr@;VBHfEmlLNuo!l)kIMP4WfIhj$>!Q(hln;H%3zW3pr1VaDYkP*gFn$ zF=yio)GP3wBC0ie`1anDi*Zccfefb&XHRH@+Cld+d$4A3J|qrTQS;cXbWh*GwilR% zo!<{vVAz>Hi1`jh$tXj8|M-QrGc$Pd(2^@Lk7T5(_`-!P0hyqBH?dUy`s+pA2oZM| zqA8n@!8~)wRMkUjga(KShZ7SXQV?J+?urjCMNC1@ot2Fg%&6kbR3o$GI0df6e8vsVY2TWF&K9Zlu;A_ZO0^KB{cw`O=_l8HvmeH#+Gp7uL zCgXUuvv$fW5n^dhBK`5anp!scqcf+rJCaq4lwLP-j3(29d;G~iaqxx{f7y4< z=C2kv#4!pq^g2G(2jNFs`riTBxNZeJ!gEYA={Jan54icmGEh7cA^PJL6e)Nzme^EC zk`Mt7OQKKqYtXTo$6o@O=}=+MM}ChVF5DOAk8qV-|Nd%zOYqE=0}aP0S2ChyJ+uy7 z#={i+ebeor&mtiy2ktQ2zuXtI1J!tnm5f&25F+vYgF13K&XPFyndUT!SF|y-%=1Qj z**$Bvm2453ky7;19n54`$K_keNJm8XQ=E%V`Vt-fNjEh%i74~ndW*BH<=B=H~W8 zB^z1?&@rK;Doni0h?9^&G{QS%JVN3+Zpm|>3vuiZyIfcTqm5Tb)6T78_b(PC*dCT3 zAem*W-*`d3I3Dq{PcFtGe4Hbf?Jnqvdfs%#q{Wn2^dSL#mGZyez4INE_EP7 zeww~yB0D4LlEjyEnkZHcfSQ{qj>I0CmAzteznEHfDr(RoyN4PPo7zeSuHIdg+L4~X z@@%|qVgUXhAq;BUU6(zfqy=12+0~G)}?OjML1>q=#V0T@hPy37u2RnRu~~~2braj4hKXobem@tX88PS zkZ_}`)zB%{q519^ZqO+>jsgYR zKOLca|D~uHasFBhX6np2OnA_Y!Ev)#vM#`!q`pX6+zIO;xk+T#!qdd=yL1{;L5~@6 zWj>SmyN=BYUI?79qP%%c`7Gk>Gn;q=NW8eDB0BBrUrA2Z@VayMl!3sD^eUe!_SOkvSSs7ULvX%43oJ3RyZ5 zoOQpFAT6}}21x2BK&TdSK`XRcA~qFRo4(Apa-LJ{xK7$DHMm9R&YxIRP3$OKggAxL zDkM=+8Ob1DnW6db|02arj7m-B(r^TV#eLX|!n$WC7?gwH_mskM1g3KGTR-HlK^CK= zEp+(~0A4*IuWrq~g=Ig6j(z?qwIP4dx)vww>nyyCv*dy8ZSAxjMLEU*&v~E`jupnH zG*yS&e@!WQ$8bv7E4Z|Nf~4N>Y1vOclXOzKY{%1|Q)3FFFPPBTWuvt%X$-XAL-}YE z$8Z=QRT=XE&aST&iO3)S9DzYlq%t$VpMcHjq!g)1Lqt?z!dw%Plr?Y%J9#_3Kh{F` zrA>`Av75CfW7B~u;sey<&X!{12ODN5+KZHG=FhSP%~EP7mBfL09Ww3g0TwX1!ENVj zQ&ii%gR}Y<7w>Bm4#vg~D!wON4o1>VS#ZaFl_UKeTQw21CQaP$6CRd{4Qik#rR8~# zm-1c_tLIyhFYd?j?((i92*Mbq1;w(V;(fAX1U$Mv0Q-2{G0L&#kHs7(=rR3H8HI8OPb^uWa>WYzhKb-S;vBd)HHHq1e&=Y*z;TAK7maol67`6C z5A6rjD?K{zcW~8n%lOyp`4#z$nDCY!wmsDBHAeO5f;e$poP(%=xe*(}`cbFs-%*Z- zog$SSM)J&etz&bR=?@G`79$aJXIyjA5uf^|ly^_wd0}}KRqsafWt6WXKQWvYdx*)p zGY#@1D$i}<;8`4X>d1n3@uW2v^Iby1jDy+5Ut>H+$8<+ral4?p9r}f;8q11p@Q`6K zD=C!JC~E1_Xd~GO`&(&9PI9MFpY`!;!pN;^v(Kyvd=s(bX2{}Qn?0>>meyoRa1YSR z$Q}z3Qs`^+*|%8Lxk#iITcLw1&ded*@iw>k{8$HMEbgqLYlDE%yDRDuqJhETH8}wo zGO0t>Sj$6CBHD1XS;q4#Mq1Mot#`8aZY&at@>zS(H4({jf0H+o@UHmjj}h5)DTC7H zyripZo~VY1#jfJ^#k*|k!S+Odbq-d_umUubw(}BX6`mC54a3}g@D6990hsv)&OTgh z&yp3>caKiip=y@SS2~HCfvV<6(&t!)X&SdNX~>pL zv-wltwYQSlS6v(gq*CRJJkG3!x)v&^p?Yvrb4arXcK2S-)v49yds$=TG$Oj|QmAa~ z55l!oFq+c&)bsoU5-gM^rosaqz0SnCu#(W-@;eR+0W}}o5;4m4y_PsAP{tVLI4GvK zxEyh8sZVc=qc?k1A;8!kPwAT9L%g+yQ<>luh*j)Fa`OTklPakT66;bS3Jel`oc8`z zH@gTVoi+gt1f+rWUk;f5>Q`<^I#IY zfVVwRV;czxPuG?LZt!aIjl`RSs~>{H<$Doz_cCRSBv2>LabP!PbzFXUnz-p?8q@Xt zcnABT`533)xRo^HS=nZKQ-6d!R;es?kw|GiR&rfyxN-!t>h;cSyWF^US`Zz5!>M*) zYmd`8xk-6EjSkSIbEY~XGim9zX0p8*xnpE2vr!M8oW5ug5)I*3Jwf*x8X^_s&YCz& z7d2U76*p|tAT5zP5hEP4>mWErrE5{{yfXB|Q=(b?Wv~3rNsKFNxMA3o@KBf;n(rAs zmu8l8!vvg{N>e$@xfJ@{jkN}=hYKA%4{s5@bx$i&Sj-J*=J^C2`6QS)Y%Jzc+7j2 z*uUTT;4Zt%?$6(3hX0=MC@FDF-NpuR8FwI~$W-M}2f+v4p!IZp= z&~MyIWEOpyRd=NJB-^L{)%dXqd#%06u&6?oe~hmZK)g8XGj5f|i>L<~QT6^4K|<;w z!OjVhN%^2vj=rP>Lf_cIjrLoI_HwbJA`645r4e|mN$JU%(<)p#O1B07k~0$~F2ZG~ zjgiwRMlR^QBgz=6H$|hiJ5r8yUlC>1+sjoF0=AK;wd6^$!Y)6RFgm}UU;!;e7cO~X z3+FE^g9~9|rBOv=A*mo1%y(i7~O522oGNt6q}M>$Wa|bk9T2- zyAQk!aba|B5#>OG;?j&U+B~>Nht_fd?pa*yG12TNQIgnQ;};b#ELFO}1oFLia?yx3 zM()Hhy&R4J4xvZ=jq=5%j6Xm~812z~ibv-n^}JrGJ?)`5B?~~DJcrN)AaCgRXx`NR z8DV$STO}auc8tY<1nvgy-?Gi$x;cWV>RrLN-|~c6cia&T?tV~;1KLc9oF+bDNpc37 z1kTo_yaVR-^r^NhIp{ScZT(B-D4NSVhenL*Nfx;Fz8X#Ix-0mWRbmI?HhUZjC}WMw zAaKZjn24kHHYm1^pH;-6(*WuzkXM{$j2g~YNAA^FW#P+jqPD#em!Q<825;UHG{x*R zhUH6f4@h77irKZ?zSMmS<0NWe@D23MSX-nF-V9MhN8acD5LdpBc)CkzX^l-m${FlK z2K4SaJgUw^l!RZ{MwrPyMS}Q2?%&#=0YDHu$~2Bo1iPXoqnaRg9HTDq>~^O*?8>-= zzyC~NQ$t^6HawtTN!SV_ z-N?XvX_N6uYfqdbvODz8G#;JPzyRglCe1^vrjj-Sr@nF;KLRR!f=%qR722Og7P#H# zN;oUOd8ZG~j#R=g`kuxL9jBgFWyc1Sv8{tFw656aYf5%a`w#sqVy|vl0Sni0TV7(a zU^S8B$<9oWB+KxG^G@40xGfWrE=A=fHZsk(yG;{-+Z{{PHL4bMn@yt z?Oaz(cw6Dp$Xe@gp4d62859`W7GhJWo);ecTphfMzI80v zpAbCvi!cF=Od>~6D=&q5s*|S2=^ukmgMud{bJ*mE0Hxj3T3adJxhLd;C+hY$YovW~ zI=6TxqXdreZZypXjrv_Jk-JBvD82?SwkzDrHm|rrZlhuoz(Pi>xwny=SRAu`LW3D} zeEd1z4DJuTe}^^pAgFgnj9oi%fOzKKwL#XrG(T<^B+ zT(0*}ZduHlNozHkcUKAq(N8OPx#Bs67frQvI)ukUA@U!zn5B(^Yh0mnB?(61UuBc= z^XyG6+kFTOBngp+2=#YvHba@5{aBx__UrEa7(pq`^Rbxh7R^ma4%@#7r})&pMDgkv zV=i>)$QW7$S~(ok&5U`0%Hg=*fuk(~_J9cMeTDB{6my5AMW>=7v2^K3TCGZL;hEBS zUZ+e|px6HO9?Hp9F{;l&x~Z@uL#!JlI_o+M>@k>$<3FL${i;fY=B`!bI~|z+VsNl1+c(1TigFS=#6{ z!rtOPxHA@a`x4wDBO9k8nN|wJ`!aVK3)6?V9GUk0puCw00!Ne7bTEe+O@XI&BHx>6 z&W37F_bh`F^y)`h1*XjdcCYO(kOm7+?+whsVW<(*dv^5D)cQsbx!|d zrW}0{KqYnma_|IAjNo<>dmlB{1mz+Teh86l$!Slxy}2>~SOS7}O`wk<=M|2OSOjY*)>T_O_`mPFZQ9 ze6$TP<;Ave(X`JyNB7lg zmn&lV5li%gJt?kXrlFKet7Rl2YlSAa6Kk7yO(1W|vGuieo|3{E0BmtgNw3*f*f>!@ zr^-jj-R zW+t`rL-j-e1|wKdmpk{abbE?`L2pdK>n90uO(V)?6xwEJU0pMtYoNKDj7jv}@SunJUQ#$eF|6}WSar(0Og_obg@cVAF|G*&zU@=vOR)RgTW^<4rewra zE>SmA?8eJc+^MT(%{oK97%L>`lx_cZYp(VbTA~&p%laR;b-AH~>?h zj*0e(#v8XmeF9>}6NWM4YbN)fs20$ zr9n%!>U_4(DubSz_LJ5Yf)_JJ9^UJ4G4J#*ungw|B-*$H4j>Z_bm@Dz=6AJ<; z(--iLp2nw@>Z)2^XWlDiCQnK=icZa^krxiRHH^unO$K6(f6<`mUfEImY829A-gQk5 zFRbt0Y=5Sc6GzjTj%>JibwpFe>fA?9#Pn@UO3T};b}d^AFVg|S17_!#-b{TXQcPQxV< zJsW9wJ-trGK0rIY8kG=)0-8frfU`TneZ18n)|skCN*axZbmm%9j&aJ^BtTbAvHPx- z|6Kh<$1EV?8J)^MW26H+Ur}pYc3(bG`txTqcL%(Zs4|4MR-Ib;>~sz7AZuJUvB&Ap z8oIE3wu#Vu+vZ|g-P{@Zg%*~wO~0wJdq$ZIV8}7-tZ`Qgn8hMnazH(Mlf_CoJbpE|9mMVsZXa`H;E3=9M%!A znQ)o}inWCYAOd`OP96AzDCe~_aqE8b`$dcP-K|hL96SSWm@%gv@9th4wc8Txeui;s zZ5f^@w*wh7gwsi6!C|aQSo&VQl-Z5+!=5WIo+NzwI58q&+9xb zo>B*Pbya}Bu&mw7c33{`Dk}^lfOjZ$_N-GUPw8K}yt13WOuEL-B@c~$MmO?lCya4- zwZ|y-Tcr#z-A`epWtN;B<8AS{M#>MfVB!=`mx)n0pZYv)w~g0)no*2A<7iOUa-^d@ zoBKI$(1s|f>b;<|bZc#`AY<}&Li1s1`GWJ-CIR;Jj~IJ`#plgq=DN#ki?v>oJfNu2 z^U?_=-=Y{mq-J5Zom(4r^ZL&9j*NT_x<_PS_Z+ylaPeQrS!v0|Rl0^j5tH1TQdc0( z3JM2rbw>}nEHdeFM>nv|GAU(7F^Ho3Wa!x*w0_7oHm!u>pIMR4ra$kXXAR~&Ja;9C z*(4<24A#}oPP$)t1g(GvAc9geN&(hhfqY?}z6R%CvX%WQ)M1!K1Ygv(7Uhb?RG;>_ zqq`{UnSJpZ4{30{|4^1;$%uI?ikgx*(+TNDa*#QILNcqa)xrNZ*va$I4Txx1!)Yo3 zO;IX$cMIgn55X8jJVw`!R0b6D5~7}zx!B=W|GUAp!dk?9*U)-F+ro;*E-F8_70wzl zI}xfO%Fk+h7qu1If)!y;QAD5Ckz{j4*`l?TJPcwcgF>b*|E89-;u|VSs_Ysw^W9S4 zs=w0Bh1YhR`?-6b_{l@}HMI#-NLEZ&5a}t5r7^(XB`%;`5-gc)qDPUPY{ZYZ|1R_c z&8DaJZzG~n9Yc60R?S#x?TF{&8KtMpTd@oC!HPr7eRDpqSVin17!Pj-Yxea9P(AFG z9M9VTO3}RQKVq1-Vsh?!6_gEqXOV5ro$2O;do^)K)}lyW@RCKN8E~un2o?8>Q#@FDIq`K$Bd) z9UEPi+Vz#QmN*6t50YgPBE~`sPIbM3l)$1a>>)McUoCc*wK^vo&Cg+wH!ZKBK6)K7 zXG53GA9g~-JKG?RK~u_=Y~kf%ek$vw*>kV+-ark)6-D9TgrXaw zDm1P^^aHmv;z{Nc^Y>j?T!-FjS}31G+*rV_QkjJ;*|UFy2wazZk2_P2`dNtf(dFXP!{+KpU>+e=r#ZqpQHZfo7@%c#%_m!aGB)C6iL~9!3(?GWdhI89T-`^|(h@ z<1JS}Yl=Rx6dQx`0Jbp_FZ6Uek%lQO(|ZQWI{mP~BvAQ~Tpj62q)bPhRpJ4?Og8#T zw?Fb#^6@1^50*}^<(1{?O<{^onJf@^@T>9)@)}JOJ^&~QECt0vdKMd$i=dM0u8CAD zaEYuW;cWHvVUExGLUr904^7zgSmdm3(`Wd{Iu1p~Cfqo2t)3&j_kCBw6a|CG<~*jn z4cx@a#b0LXw&k?!cB?9`oG8~8Z%1i|7t0vzEaMqLtt(GPn6!i`HJHa4%m4lBZk+-v zZddrbRW-0Xbl4S%Ow2iTd9(W*?3b7R_NV`dve#-%+qZ4lr=4E$;57&_D_wDU>WJ+Kbs{c0qN@2{|#vb@rtYZOwAsRh=)wZeH}`QyS{zW?^cH zPP2#&7^fA8?$f`N4{+rfe#sgg`!rJ5(z1K}l_^IYJzXyiu!yzhFK(-nTMcZtR}GKj z!ZO!b=d}mX?l!U+{^G}~b4fG8VQf3lgm`UA5F}PpM3&&cM0hnwLH&g87IUg|S7Fb_ z-6^W8x^@1Ay|VDDu>(SWPm1$$fd#}rNaZA&?(dr0a+L)^0~#SfHTqXwW(HTuJ4hH&zjIEWoTIwm$`Ii?;u>01>>7Q4K*yHy7++ zj*5R6Q*L{Eb(pm#PC!xdl{~NA;s7Y8k06q%^zA+pEaBvb?)%#rsMZ32y9Jg)6rs5) zQ99)@M3MIHHVDKvxc~s&S7tV9Xo~W+5EytA9ZjcYiY`GaXRx=cPK7VcGoMe!uo7ro zL!NDB#f6~1VE_lB>sE+Sq=(-VjJa0ke zWWSN&LJfoO6ebTH9A*1=K0Tw})=d_KTCXcn-%~=s5^jz0?E+Y#-Ou%ng}hyJQaSJ= zeW?|aq7f;^UESbnCQ%Z~(3(?|a<&VZVNzJeNHVhRBmigBEF1)`e9G@xTw#fl=#OA{ z`~ZCl31m3;NZzLqqQY=Dx8?U(mfEx^{20;}n}Nkx9N8s1)z&Ju9&aXvXH~aa%Qdj= z4l{&N7ng6HL;&{3-?_}I6k@VwNcly=pM}LM6awc6bGq1Hd!2$KXj-(bJpYa&LGl4| zPX1AsJ2_prh+Uhq@|K9@k7DCrkf6O;DN2LtJMz{A%aR1iF(TBzc;P&RHWOY|(U_0M zAQ2d<$aJho@Stdzg51R#Bf&OAG?|Zw1D;x)|3XR9fkXa|HywgBM7YQ6@X)wBmi1ff znq(;$X!`}xI>@Ks*D2^RTC2mvG8vnPGy3U4x%nn3z~mgyvB!taSDq6QqI?tHCdU9N znmCN`ALor)sl%MW$KvmOF~Cz;5qI*zIIpj1Q6X9^lV%G#Iff#hPC6#UzCsA7)J|YH zf?z?5nCI}J`tN=X68-rn9ZxN=hUq+LHXvvC^@!;dNN*s772l%*iIsn-#&E-9&W0rn z=uI{bpgd#KWJ(gD_T=?_e=Y|J-lCcOL%GAkttC$m2??b58EbLz6S}ni$v2lDg<1~K z%%x1tV;~>(LEIFf!5d;J_pWAg7q@I?JWw1UX$}*E6?@qtS!CD%vI(3Qr>F}Dbpj80 zqZ?1q#AT9EmqyS1$kS#g;T*n$tM0ZAas8P-)#5Z%M(?yDvEd;3a+f_infxVknT_x7 zShQC6JY+9d*)$z5)F*$Uj_hKhJ7nJxno*}3H)$$M@QQwu`tSMGY^TK-S_;)pQ6&4l zi3m9)Rj91|3=Fa6`l2jHx+8)NB3T`&@BUq%iAeg)NBDkk2Z1XasnYcuD%cNACNJzC zgaLwF5;L+?nsJZ&-m7G!O)DleWd;T1MhOhT=pqWHt>OE{pWBmYEAU6Msf9qPr~49$ zJneK#3<`Sef)Wv2o~5`eZgBb7O3+>>fc+G|?_1_b|HtXr2^|r)IL4>;2F5~M?tcLf zQGl9MJapUDwJqGzAJ>hxK{R>>Qumn@AkvzGaL*>0Uy;$2vs)eMwzXax?ltm=lnzyO{~3+2a4aP9Qw91YVe<5?!4kn{^_yNOE% zjt5P^xH^aP2vegaC&qah*R=CzhU}oRVr63jIuOivJ&)n9k|!JI47u2h9C=nNW>0ZM z4Ng?_^dne5asLJ170WI4{W$oTfsqIV@?+$VVJk1u*6Nd!iO&!ef>yfPs6peX_DZqJ+|O-Sq2Y`#Ij2~D zTW(uc+Q-egLP19IQ}dkI<8~;WP^~Lv3WlERKh-Z;uEf9-KJA0BNuX%tCdWQ~X6aK= zNlT+cdJC)4#A4o8dWqH-)O4Xb<|nJ71r)QL#8oIG1FI<=>a)n!F-;%J6qRadS@}1j zHzREf>6zR#Zvurg@{-6WfU;%|T*mxbg>LWejTd7LXb|=j&p4J~+}4DV6a=5eVbp#jt}@Nw>g@J{ z37zm1t52zXGxUWN9U0#5f@024;E4v;2QJTg;&g52g^>c#R$=8h1mCiIGs}mNP9t*U ziHqCY6E;g-PtRnmR64IjJ!GQaN%C?OY6%6$GjV!FY4) zbq_(j$&P+>jm}r^r0t5jB(0n%V-`<|!P#NSDlP9Ksd^Lp3^&cxLR0WYgoDP`23hTt zFISy3HnhNUhK=6iz-`GoEy|KgH-nYs6oXU8YKcefNAEcSykcbgr}yTgQ&TYdFom0X z592O-@?%J?MQNN;dMfPPQKKSLj3C7L71k6QW2%+M?xkjFR3%lyXV^O@!tAqU^=C?2 zt#CxDey1GKOWAC(^n9LV*|?kFg$C9Za>{5<1wjS0}|-J5m$P!*f7tpgp@Jd0X06SW0%iHzoP<*g%KOox3~;IiW8_ab{R_ z7}*SY$+!5?=pj%}?daISfHMVci&#E_3#_WRh|cp=@$piAN?p?jSrghHxSOL!Zour> zo_UxVLAYdu@ao3C8KAMmGASf0Ip%p6Q~*!E4Ba19*W!Uf&tU7b0RfEqYsMBNBqis6 zi90YVfG-z>bBjg|27eRYgQP~Y71%0D7(Xf4^J=_2VpQAVWh3iITvJ+3PlR4G=w2mq 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 IBi~T|2QBv1ZvX%Q literal 0 HcmV?d00001 diff --git a/Plugins/UnityPurchasing/Android/billing-3.0.3.aar.meta b/Plugins/UnityPurchasing/Android/billing-4.0.0.aar.meta similarity index 93% rename from Plugins/UnityPurchasing/Android/billing-3.0.3.aar.meta rename to Plugins/UnityPurchasing/Android/billing-4.0.0.aar.meta index ac16c9c..66ba8a5 100644 --- a/Plugins/UnityPurchasing/Android/billing-3.0.3.aar.meta +++ b/Plugins/UnityPurchasing/Android/billing-4.0.0.aar.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 48b049ac318f85e50213169f56192c3a +guid: 0bb9191753e1b4bee94ce0300ca911e5 PluginImporter: externalObjects: {} serializedVersion: 2 diff --git a/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing b/Plugins/UnityPurchasing/unitypurchasing.bundle/Contents/MacOS/unitypurchasing index 798d0645ee82adf5378eb6dd9a699985758e393a..1b99df9c1210080f673d37c34bcb5e085779b8a8 100644 GIT binary patch delta 187 zcmbQxCp4i?s9_6ZLlQIVu3OuilNfs$!7Skv#!qWRrnIV-l>09Wc+Jei9bf1y>Ha~f zc)O$^(@ACp7E_b5hZmHWEGRfzU(9JL6_qrd`B$Fq$zGxMb#sLe|88dzW!lap$}D#P zY@YH}=2(cy&DWUuQblIJOq~7g(q`G)KMq*FiV**G%keJT_NCS=H<=agY))W|5)4b) jWFTJ`yy(lD>H4M-$2LyUt+>2FDR;$E?(OsJS&G~NnmJC8 delta 187 zcmbQxCp4i?s9_6ZLlSej%7yLCNsPUWV3u$STQ=4WID;L!1a%NS@n{|3b$D`ZyJ^?+r%Hk>-(+!w_)x@VUJHfn_b$OM47fTi89L_ z0Gp?Ll{pq-a`QE2zEqJe>nf+4ZzfN(>-HDoeYsS^kF!i*`%-I`o6HIir$~r The id of the product. /// The reason for the purchase failure /// The message containing details about the failed purchase. - public PurchaseFailureDescription(string productId, PurchaseFailureReason reason, string message) + public PurchaseFailureDescription(string? productId, PurchaseFailureReason reason, string message) { this.productId = productId; this.reason = reason; @@ -24,7 +26,7 @@ public PurchaseFailureDescription(string productId, PurchaseFailureReason reason /// /// The store specific product ID. /// - public string productId { get; private set; } + public string? productId { get; private set; } /// /// The reason for the failure. diff --git a/Runtime/Stores/Android/Common/AAR/AndroidJavaObjectExtensions.cs b/Runtime/Stores/Android/Common/AAR/AndroidJavaObjectExtensions.cs index acb37ab..46eb079 100644 --- a/Runtime/Stores/Android/Common/AAR/AndroidJavaObjectExtensions.cs +++ b/Runtime/Stores/Android/Common/AAR/AndroidJavaObjectExtensions.cs @@ -14,8 +14,12 @@ internal static IEnumerable Enumerate(this AndroidJavaObject androidJavaLi internal static IEnumerable EnumerateAndWrap(this AndroidJavaObject androidJavaList) { - return androidJavaList.Enumerate() - .Select(javaObject => javaObject.Wrap()); + return androidJavaList.Enumerate().Wrap(); + } + + internal static IEnumerable Wrap(this IEnumerable androidJavaList) + { + return androidJavaList.Select(javaObject => javaObject.Wrap()); } internal static IAndroidJavaObjectWrapper Wrap(this AndroidJavaObject androidJavaObject) diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs deleted file mode 100644 index b76533d..0000000 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace UnityEngine.Purchasing -{ - enum GoogleBillingConnectionState - { - Disconnected, - Connecting, - Connected - } -} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleFinishTransactionService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleFinishTransactionService.cs index bd973b3..45d9880 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleFinishTransactionService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GoogleFinishTransactionService.cs @@ -1,5 +1,8 @@ +#nullable enable + using System; using System.Linq; +using System.Threading.Tasks; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; @@ -9,33 +12,49 @@ class GoogleFinishTransactionService : IGoogleFinishTransactionService { IGoogleBillingClient m_BillingClient; IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; - internal GoogleFinishTransactionService(IGoogleBillingClient billingClient, IGoogleQueryPurchasesService googleQueryPurchasesService) + + internal GoogleFinishTransactionService(IGoogleBillingClient billingClient, + IGoogleQueryPurchasesService googleQueryPurchasesService) { m_BillingClient = billingClient; m_GoogleQueryPurchasesService = googleQueryPurchasesService; } - public void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge) + public async void FinishTransaction(ProductDefinition product, string purchaseToken, + Action onTransactionFinished) { - m_GoogleQueryPurchasesService.QueryPurchases(purchases => + try { - foreach (var purchase in purchases.Where(PurchaseToFinishTransaction(product))) + var purchase = await FindPurchase(purchaseToken); + if (purchase.IsPurchased()) { - if (product.type == ProductType.Consumable) - { - m_BillingClient.ConsumeAsync(purchaseToken, product, purchase, onConsume); - } - else if (!purchase.IsAcknowledged()) - { - m_BillingClient.AcknowledgePurchase(purchaseToken, product, purchase, onAcknowledge); - } + FinishTransactionForPurchase(purchase, product, purchaseToken, onTransactionFinished); } - }); + } + catch (InvalidOperationException) { } + } + + async Task FindPurchase(string purchaseToken) + { + var purchases = await m_GoogleQueryPurchasesService.QueryPurchases(); + var purchaseToFinish = + purchases.NonNull().First(purchase => purchase.purchaseToken == purchaseToken); + + return purchaseToFinish; } - static Func PurchaseToFinishTransaction(ProductDefinition product) + private void FinishTransactionForPurchase(IGooglePurchase purchase, ProductDefinition product, + string purchaseToken, + Action onTransactionFinished) { - return purchase => purchase != null && purchase.sku == product.storeSpecificId && purchase.IsPurchased(); + if (product.type == ProductType.Consumable) + { + m_BillingClient.ConsumeAsync(purchaseToken, result => onTransactionFinished(result, purchase)); + } + else if (!purchase.IsAcknowledged()) + { + m_BillingClient.AcknowledgePurchase(purchaseToken, result => onTransactionFinished(result, purchase)); + } } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs index 9b64281..0b95419 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GoogleLastKnownProductService.cs @@ -1,30 +1,15 @@ +#nullable enable + using UnityEngine.Purchasing.Interfaces; namespace UnityEngine.Purchasing { class GoogleLastKnownProductService : IGoogleLastKnownProductService { - string m_LastKnownProductId = null; - GooglePlayProrationMode? m_LastKnownProrationMode = GooglePlayProrationMode.UnknownSubscriptionUpgradeDowngradePolicy; - - public string GetLastKnownProductId() - { - return m_LastKnownProductId; - } - - public void SetLastKnownProductId(string lastKnownProductId) - { - m_LastKnownProductId = lastKnownProductId; - } - - public GooglePlayProrationMode? GetLastKnownProrationMode() - { - return m_LastKnownProrationMode; - } + public string? LastKnownOldProductId { get; set; } + public string? LastKnownProductId { get; set; } - public void SetLastKnownProrationMode(GooglePlayProrationMode? lastKnownProrationMode) - { - m_LastKnownProrationMode = lastKnownProrationMode; - } + public GooglePlayProrationMode? LastKnownProrationMode { get; set; } = + GooglePlayProrationMode.UnknownSubscriptionUpgradeDowngradePolicy; } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs index 9472772..efdae37 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading.Tasks; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; @@ -12,14 +13,13 @@ class GooglePlayStoreService : IGooglePlayStoreService { const int k_MaxConnectionAttempts = 1; - GoogleBillingConnectionState m_GoogleConnectionState = GoogleBillingConnectionState.Disconnected; - int m_CurrentConnectionAttempts = 0; + int m_CurrentConnectionAttempts; IGoogleBillingClient m_BillingClient; IBillingClientStateListener m_BillingClientStateListener; IQuerySkuDetailsService m_QuerySkuDetailsService; Queue m_ProductsToQuery = new Queue(); - Queue>> m_OnPurchaseSucceededQueue = new Queue>>(); + Queue>> m_OnPurchaseSucceededQueue = new Queue>>(); IGooglePurchaseService m_GooglePurchaseService; IGoogleFinishTransactionService m_GoogleFinishTransactionService; IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; @@ -58,14 +58,13 @@ void InitConnectionWithGooglePlay() void StartConnection() { - m_GoogleConnectionState = GoogleBillingConnectionState.Connecting; m_CurrentConnectionAttempts++; m_BillingClient.StartConnection(m_BillingClientStateListener); } public void ResumeConnection() { - if (m_GoogleConnectionState == GoogleBillingConnectionState.Disconnected) + if (m_BillingClient.GetConnectionState() == GoogleBillingConnectionState.Disconnected) { StartConnection(); } @@ -78,7 +77,6 @@ public bool IsConnectionReady() void OnConnected() { - m_GoogleConnectionState = GoogleBillingConnectionState.Connected; m_CurrentConnectionAttempts = 0; DequeueQueryProducts(); @@ -92,7 +90,8 @@ protected virtual void DequeueQueryProducts() while (m_ProductsToQuery.Count > 0 && !stop) { - switch (m_GoogleConnectionState) + var currentConnectionState = m_BillingClient.GetConnectionState(); + switch (currentConnectionState) { case GoogleBillingConnectionState.Connected: { @@ -117,7 +116,7 @@ protected virtual void DequeueQueryProducts() default: { Debug.LogErrorFormat("GooglePlayStoreService state ({0}) unrecognized, cannot process ProductDescriptionQuery", - m_GoogleConnectionState); + currentConnectionState); stop = true; break; } @@ -141,7 +140,6 @@ protected virtual void DequeueFetchPurchases() void OnDisconnected() { - m_GoogleConnectionState = GoogleBillingConnectionState.Disconnected; DequeueQueryProducts(); AttemptReconnection(); } @@ -165,13 +163,13 @@ bool AreConnectionAttemptsExhausted() void OnReconnectionFailure() { - m_GoogleConnectionState = GoogleBillingConnectionState.Disconnected; DequeueQueryProducts(); } public virtual void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) { - if (m_GoogleConnectionState == GoogleBillingConnectionState.Connected) + var currentConnectionState = m_BillingClient.GetConnectionState(); + if (currentConnectionState == GoogleBillingConnectionState.Connected) { m_QuerySkuDetailsService.QueryAsyncSku(products, onProductsReceived); } @@ -183,7 +181,7 @@ public virtual void RetrieveProducts(ReadOnlyCollection produ void HandleRetrieveProductsNotConnected(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductsFailed) { - if (m_GoogleConnectionState == GoogleBillingConnectionState.Disconnected) + if (m_BillingClient.GetConnectionState() == GoogleBillingConnectionState.Disconnected) { var reason = AreConnectionAttemptsExhausted() ? GoogleRetrieveProductsFailureReason.BillingServiceUnavailable : GoogleRetrieveProductsFailureReason.BillingServiceDisconnected; onRetrieveProductsFailed(reason); @@ -199,21 +197,23 @@ public void Purchase(ProductDefinition product) public virtual void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) { - m_GoogleLastKnownProductService.SetLastKnownProductId(product.storeSpecificId); - m_GoogleLastKnownProductService.SetLastKnownProrationMode(desiredProrationMode); + m_GoogleLastKnownProductService.LastKnownOldProductId = oldProduct?.definition.storeSpecificId; + m_GoogleLastKnownProductService.LastKnownProductId = product.storeSpecificId; + m_GoogleLastKnownProductService.LastKnownProrationMode = desiredProrationMode; m_GooglePurchaseService.Purchase(product, oldProduct, desiredProrationMode); } - public void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge) + public void FinishTransaction(ProductDefinition product, string purchaseToken, Action onTransactionFinished) { - m_GoogleFinishTransactionService.FinishTransaction(product, purchaseToken, onConsume, onAcknowledge); + m_GoogleFinishTransactionService.FinishTransaction(product, purchaseToken, onTransactionFinished); } - public void FetchPurchases(Action> onQueryPurchaseSucceed) + public async void FetchPurchases(Action> onQueryPurchaseSucceed) { - if (m_GoogleConnectionState == GoogleBillingConnectionState.Connected) + if (m_BillingClient.GetConnectionState() == GoogleBillingConnectionState.Connected) { - m_GoogleQueryPurchasesService.QueryPurchases(onQueryPurchaseSucceed); + var purchases = await m_GoogleQueryPurchasesService.QueryPurchases(); + onQueryPurchaseSucceed(purchases); } else { diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePurchaseService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePurchaseService.cs index edb94e2..3ac47d8 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePurchaseService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePurchaseService.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.Collections.Generic; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; @@ -18,7 +20,7 @@ internal GooglePurchaseService(IGoogleBillingClient billingClient, IGooglePurcha m_QuerySkuDetailsService = querySkuDetailsService; } - public void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) + public void Purchase(ProductDefinition product, Product? oldProduct, GooglePlayProrationMode? desiredProrationMode) { m_QuerySkuDetailsService.QueryAsyncSku(product, skus => @@ -27,35 +29,78 @@ public void Purchase(ProductDefinition product, Product oldProduct, GooglePlayPr }); } - void OnQuerySkuDetailsResponse(List skus, ProductDefinition productToBuy, Product oldProduct, GooglePlayProrationMode? desiredProrationMode) + void OnQuerySkuDetailsResponse(List skus, ProductDefinition productToBuy, Product? oldProduct, GooglePlayProrationMode? desiredProrationMode) { - if (skus?.Count > 0) + if (ValidateQuerySkuDetailsResponseParams(skus, productToBuy, oldProduct)) { - AndroidJavaObject sku = skus[0]; - VerifyAndWarnIfMoreThanOneSku(skus, sku); - AndroidJavaObject billingResult = m_BillingClient.LaunchBillingFlow(sku, oldProduct?.definition?.storeSpecificId, oldProduct?.transactionID, desiredProrationMode); - HandleBillingFlowResult(new GoogleBillingResult(billingResult), sku); + LaunchGoogleBillingFlow(skus[0], oldProduct, desiredProrationMode); } - else + } + + bool ValidateQuerySkuDetailsResponseParams(List skus, ProductDefinition productToBuy, Product? oldProduct) + { + if (!ValidateSkus(skus)) { - m_GooglePurchaseCallback.OnPurchaseFailed( - new PurchaseFailureDescription( - productToBuy.id, - PurchaseFailureReason.ProductUnavailable, - "SKU does not exist in the store." - ) - ); + PurchaseFailedSkuNotFound(productToBuy); + return false; + } + + if (!ValidateOldProduct(oldProduct)) + { + PurchaseFailedInvalidOldProduct(productToBuy, oldProduct); + return false; } + + return true; } - static void VerifyAndWarnIfMoreThanOneSku(List skus, AndroidJavaObject sku) + bool ValidateSkus(List? skus) { - if (skus.Count > 1) + VerifyAndWarnIfMoreThanOneSku(skus); + return skus?.Count > 0; + } + + static void VerifyAndWarnIfMoreThanOneSku(List? skus) + { + if (skus?.Count > 1) { - Debug.LogWarning(GoogleBillingStrings.getWarningMessageMoreThanOneSkuFound(sku.Call("getSku"))); + Debug.LogWarning(GoogleBillingStrings.getWarningMessageMoreThanOneSkuFound(skus[0].Call("getSku"))); } } + void PurchaseFailedSkuNotFound(ProductDefinition productToBuy) + { + m_GooglePurchaseCallback.OnPurchaseFailed( + new PurchaseFailureDescription( + productToBuy.id, + PurchaseFailureReason.ProductUnavailable, + "SKU does not exist in the store." + ) + ); + } + + bool ValidateOldProduct(Product? oldProduct) + { + return oldProduct?.transactionID != ""; + } + + void PurchaseFailedInvalidOldProduct(ProductDefinition productToBuy, Product? oldProduct) + { + m_GooglePurchaseCallback.OnPurchaseFailed( + new PurchaseFailureDescription( + productToBuy.id, + PurchaseFailureReason.ProductUnavailable, + "Invalid transaction id for old product: " + oldProduct?.definition.id + ) + ); + } + + void LaunchGoogleBillingFlow(AndroidJavaObject productToPurchase, Product? oldProduct, GooglePlayProrationMode? desiredProrationMode) + { + var billingResult = m_BillingClient.LaunchBillingFlow(productToPurchase, oldProduct?.transactionID, desiredProrationMode); + HandleBillingFlowResult(new GoogleBillingResult(billingResult), productToPurchase); + } + void HandleBillingFlowResult(IGoogleBillingResult billingResult, AndroidJavaObject sku) { if (billingResult.responseCode != GoogleBillingResponseCode.Ok) diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleQueryPurchasesService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GoogleQueryPurchasesService.cs index 744a4c8..3589a07 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleQueryPurchasesService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GoogleQueryPurchasesService.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; @@ -8,46 +10,36 @@ namespace UnityEngine.Purchasing class GoogleQueryPurchasesService : IGoogleQueryPurchasesService { IGoogleBillingClient m_BillingClient; - IGoogleCachedQuerySkuDetailsService m_CachedQuerySkuDetailsService; + IGooglePurchaseBuilder m_PurchaseBuilder; - internal GoogleQueryPurchasesService(IGoogleBillingClient billingClient, IGoogleCachedQuerySkuDetailsService cachedQuerySkuDetailsService) + internal GoogleQueryPurchasesService(IGoogleBillingClient billingClient, IGooglePurchaseBuilder purchaseBuilder) { m_BillingClient = billingClient; - m_CachedQuerySkuDetailsService = cachedQuerySkuDetailsService; + m_PurchaseBuilder = purchaseBuilder; } - public void QueryPurchases(Action> onQueryPurchaseSucceed) + public async Task> QueryPurchases() { - HandleGooglePurchaseResult(QueryPurchasesWithSkuType(GoogleSkuTypeEnum.Sub()), googlePurchasesInSubs => - { - HandleGooglePurchaseResult(QueryPurchasesWithSkuType(GoogleSkuTypeEnum.InApp()), googlePurchasesInApps => - { - HandleOnQueryPurchaseReceived(onQueryPurchaseSucceed, googlePurchasesInSubs, googlePurchasesInApps); - }); - }); - + var purchaseResults = await Task.WhenAll(QueryPurchasesWithSkuType(GoogleSkuTypeEnum.Sub()), QueryPurchasesWithSkuType(GoogleSkuTypeEnum.InApp())); + return purchaseResults.SelectMany(result => result).ToList(); } - static void HandleOnQueryPurchaseReceived(Action> onQueryPurchaseSucceed, List googlePurchasesInSubs, List googlePurchasesInApps) + Task> QueryPurchasesWithSkuType(string skuType) { - List queriedPurchase = googlePurchasesInSubs; - if (googlePurchasesInApps.Count > 0) - { - queriedPurchase.AddRange(googlePurchasesInApps); - } - - onQueryPurchaseSucceed(queriedPurchase); - } + var taskCompletion = new TaskCompletionSource>(); + m_BillingClient.QueryPurchasesAsync(skuType, + (billingResult, purchases) => + { + var result = IsResultOk(billingResult) ? m_PurchaseBuilder.BuildPurchases(purchases) : Enumerable.Empty(); + taskCompletion.SetResult(result); + }); - GooglePurchaseResult QueryPurchasesWithSkuType(string skuType) - { - AndroidJavaObject javaPurchaseResult = m_BillingClient.QueryPurchase(skuType); - return new GooglePurchaseResult(javaPurchaseResult, m_CachedQuerySkuDetailsService); + return taskCompletion.Task; } - void HandleGooglePurchaseResult(GooglePurchaseResult purchaseResult, Action> onPurchaseResult) + static bool IsResultOk(IGoogleBillingResult result) { - onPurchaseResult(purchaseResult.m_ResponseCode == GoogleBillingResponseCode.Ok ? purchaseResult.m_Purchases : new List()); + return result.responseCode == GoogleBillingResponseCode.Ok; } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs index 11f152f..1b13ca3 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleBillingClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Utils; namespace UnityEngine.Purchasing.Interfaces { @@ -9,11 +10,12 @@ interface IGoogleBillingClient void StartConnection(IBillingClientStateListener billingClientStateListener); void EndConnection(); bool IsReady(); - AndroidJavaObject QueryPurchase(string skuType); + GoogleBillingConnectionState GetConnectionState(); + void QueryPurchasesAsync(string skuType, Action> onQueryPurchasesResponse); void QuerySkuDetailsAsync(List skus, string type, Action> onSkuDetailsResponseAction); - AndroidJavaObject LaunchBillingFlow(AndroidJavaObject sku, string oldSku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode); - void ConsumeAsync(string purchaseToken, ProductDefinition product, GooglePurchase googlePurchase, Action onConsume); - void AcknowledgePurchase(string purchaseToken, ProductDefinition product, GooglePurchase googlePurchase, Action onAcknowledge); + AndroidJavaObject LaunchBillingFlow(AndroidJavaObject sku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode); + void ConsumeAsync(string purchaseToken, Action onConsume); + void AcknowledgePurchase(string purchaseToken, Action onAcknowledge); void SetObfuscationAccountId(string obfuscationAccountId); void SetObfuscationProfileId(string obfuscationProfileId); void LaunchPriceChangeConfirmationFlow(AndroidJavaObject skuDetails, GooglePriceChangeConfirmationListener listener); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleFinishTransactionService.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleFinishTransactionService.cs index 8ccdfcd..147f2ee 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleFinishTransactionService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleFinishTransactionService.cs @@ -5,6 +5,6 @@ namespace UnityEngine.Purchasing.Interfaces { interface IGoogleFinishTransactionService { - void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge); + void FinishTransaction(ProductDefinition product, string purchaseToken, Action onTransactionFinished); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleLastKnownProductService.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleLastKnownProductService.cs index 35823f3..323272f 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleLastKnownProductService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleLastKnownProductService.cs @@ -1,13 +1,11 @@ +#nullable enable + namespace UnityEngine.Purchasing.Interfaces { interface IGoogleLastKnownProductService { - string GetLastKnownProductId(); - - void SetLastKnownProductId(string lastKnownProductId); - - GooglePlayProrationMode? GetLastKnownProrationMode(); - - void SetLastKnownProrationMode(GooglePlayProrationMode? lastKnownProrationMode); + string? LastKnownOldProductId { get; set; } + string? LastKnownProductId { get; set; } + GooglePlayProrationMode? LastKnownProrationMode { get; set; } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs index d20906f..f872bdf 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePlayStoreService.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading.Tasks; using UnityEngine.Purchasing.Extension; -using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing.Interfaces @@ -12,8 +12,8 @@ interface IGooglePlayStoreService void RetrieveProducts(ReadOnlyCollection products, Action> onProductsReceived, Action onRetrieveProductFailed); void Purchase(ProductDefinition product); void Purchase(ProductDefinition product, Product oldProduct, GooglePlayProrationMode? desiredProrationMode); - void FinishTransaction(ProductDefinition product, string purchaseToken, Action onConsume, Action onAcknowledge); - void FetchPurchases(Action> onQueryPurchaseSucceed); + void FinishTransaction(ProductDefinition product, string purchaseToken, Action onTransactionFinished); + void FetchPurchases(Action> onQueryPurchaseSucceed); void SetObfuscatedAccountId(string obfuscatedAccountId); void SetObfuscatedProfileId(string obfuscatedProfileId); void ConfirmSubscriptionPriceChange(ProductDefinition product, Action onPriceChangeAction); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs new file mode 100644 index 0000000..3589fc3 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs @@ -0,0 +1,24 @@ +#nullable enable + +using System.Collections.Generic; +using UnityEngine.Purchasing.Utils; + +namespace UnityEngine.Purchasing.Interfaces +{ + internal interface IGooglePurchase + { + IAndroidJavaObjectWrapper javaPurchase { get; } + int purchaseState { get; } + List skus { get; } + string orderId { get; } + string receipt { get; } + string signature { get; } + string originalJson { get; } + string purchaseToken { get; } + string? sku { get; } + + bool IsAcknowledged(); + + bool IsPurchased(); + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs.meta new file mode 100644 index 0000000..4d8dd34 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchase.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 318df9a589f34bacbd78ba634263ed15 +timeCreated: 1657302693 \ No newline at end of file diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs new file mode 100644 index 0000000..07333cc --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using UnityEngine.Purchasing.Models; +using UnityEngine.Purchasing.Utils; + +namespace UnityEngine.Purchasing.Interfaces +{ + interface IGooglePurchaseBuilder + { + IEnumerable BuildPurchases(IEnumerable purchases); + IGooglePurchase BuildPurchase(IAndroidJavaObjectWrapper purchase); + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs.meta similarity index 83% rename from Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs.meta rename to Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs.meta index b20b04d..db72596 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs.meta +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseBuilder.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 7ace3bcefb47d4235bcd966654b24c6c +guid: 83c2cd7a09768492faf568b7c0c53fde MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseCallback.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseCallback.cs index 2460b8e..93124d4 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseCallback.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGooglePurchaseCallback.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using UnityEngine.Purchasing.Extension; +using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing.Interfaces { @@ -6,9 +8,9 @@ interface IGooglePurchaseCallback { void SetStoreCallback(IStoreCallback storeCallback); void SetStoreConfiguration(IGooglePlayConfigurationInternal configuration); - void OnPurchaseSuccessful(string sku, string receipt, string purchaseToken); + void OnPurchaseSuccessful(IGooglePurchase purchase, string receipt, string purchaseToken); void OnPurchaseFailed(PurchaseFailureDescription purchaseFailureDescription); - void NotifyDeferredPurchase(string sku, string receipt, string purchaseToken); + void NotifyDeferredPurchase(IGooglePurchase purchase, string receipt, string purchaseToken); void NotifyDeferredProrationUpgradeDowngradeSubscription(string sku); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleQueryPurchasesService.cs b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleQueryPurchasesService.cs index 5c5198c..00abc07 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleQueryPurchasesService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Interfaces/IGoogleQueryPurchasesService.cs @@ -1,11 +1,11 @@ -using System; using System.Collections.Generic; +using System.Threading.Tasks; using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing.Interfaces { interface IGoogleQueryPurchasesService { - void QueryPurchases(Action> onQueryPurchaseSucceed); + Task> QueryPurchases(); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleAcknowledgePurchaseListener.cs b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleAcknowledgePurchaseListener.cs index bf5d681..b284ab7 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleAcknowledgePurchaseListener.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleAcknowledgePurchaseListener.cs @@ -12,22 +12,18 @@ class GoogleAcknowledgePurchaseListener : AndroidJavaProxy { const string k_AndroidAcknowledgePurchaseResponseListenerClassName = "com.android.billingclient.api.AcknowledgePurchaseResponseListener"; - Action m_OnAcknowledgePurchaseResponse; + Action m_OnAcknowledgePurchaseResponse; - ProductDefinition m_Product; - GooglePurchase m_Purchase; - internal GoogleAcknowledgePurchaseListener(ProductDefinition product, GooglePurchase purchase, Action onAcknowledgePurchaseResponseAction) + internal GoogleAcknowledgePurchaseListener(Action onAcknowledgePurchaseResponseAction) : base(k_AndroidAcknowledgePurchaseResponseListenerClassName) { - m_Product = product; - m_Purchase = purchase; m_OnAcknowledgePurchaseResponse = onAcknowledgePurchaseResponseAction; } [Preserve] void onAcknowledgePurchaseResponse(AndroidJavaObject billingResult) { - m_OnAcknowledgePurchaseResponse(m_Product, m_Purchase, new GoogleBillingResult(billingResult)); + m_OnAcknowledgePurchaseResponse(new GoogleBillingResult(billingResult)); } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleConsumeResponseListener.cs b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleConsumeResponseListener.cs index f002277..2a7981a 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleConsumeResponseListener.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GoogleConsumeResponseListener.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using UnityEngine.Purchasing.Models; using UnityEngine.Scripting; @@ -12,22 +13,18 @@ class GoogleConsumeResponseListener : AndroidJavaProxy { const string k_AndroidConsumeResponseListenerClassName = "com.android.billingclient.api.ConsumeResponseListener"; - ProductDefinition m_Product; - GooglePurchase m_Purchase; - Action m_OnConsumeResponse; + Action m_OnConsumeResponse; - internal GoogleConsumeResponseListener(ProductDefinition product, GooglePurchase purchase, Action onConsumeResponseAction) + internal GoogleConsumeResponseListener(Action onConsumeResponseAction) : base(k_AndroidConsumeResponseListenerClassName) { - m_Product = product; - m_Purchase = purchase; m_OnConsumeResponse = onConsumeResponseAction; } [Preserve] void onConsumeResponse(AndroidJavaObject billingResult, string purchaseToken) { - m_OnConsumeResponse(m_Product, m_Purchase, new GoogleBillingResult(billingResult), purchaseToken); + m_OnConsumeResponse(new GoogleBillingResult(billingResult)); } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs index 4f0bb62..9b6f3f7 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchaseUpdatedListener.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Models; @@ -19,12 +20,13 @@ class GooglePurchaseUpdatedListener : AndroidJavaProxy, IGooglePurchaseUpdatedLi IGoogleLastKnownProductService m_LastKnownProductService; IGooglePurchaseCallback m_GooglePurchaseCallback; + IGooglePurchaseBuilder m_PurchaseBuilder; IGoogleCachedQuerySkuDetailsService m_GoogleCachedQuerySkuDetailsService; IGooglePurchaseStateEnumProvider m_GooglePurchaseStateEnumProvider; IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; internal GooglePurchaseUpdatedListener(IGoogleLastKnownProductService googleLastKnownProductService, - IGooglePurchaseCallback googlePurchaseCallback, + IGooglePurchaseCallback googlePurchaseCallback, IGooglePurchaseBuilder purchaseBuilder, IGoogleCachedQuerySkuDetailsService googleCachedQuerySkuDetailsService, IGooglePurchaseStateEnumProvider googlePurchaseStateEnumProvider, IGoogleQueryPurchasesService googleQueryPurchasesService = null) @@ -35,6 +37,7 @@ internal GooglePurchaseUpdatedListener(IGoogleLastKnownProductService googleLast m_GoogleCachedQuerySkuDetailsService = googleCachedQuerySkuDetailsService; m_GooglePurchaseStateEnumProvider = googlePurchaseStateEnumProvider; m_GoogleQueryPurchasesService = googleQueryPurchasesService; + m_PurchaseBuilder = purchaseBuilder; } public void SetGoogleQueryPurchaseService(IGoogleQueryPurchasesService googleFetchPurchases) @@ -51,11 +54,11 @@ public void SetGoogleQueryPurchaseService(IGoogleQueryPurchasesService googleFet void onPurchasesUpdated(AndroidJavaObject billingResult, AndroidJavaObject javaPurchasesList) { IGoogleBillingResult result = new GoogleBillingResult(billingResult); - var purchases = javaPurchasesList.EnumerateAndWrap(); + var purchases = m_PurchaseBuilder.BuildPurchases(javaPurchasesList.EnumerateAndWrap()).ToList(); OnPurchasesUpdated(result, purchases); } - internal void OnPurchasesUpdated(IGoogleBillingResult result, IEnumerable purchases) + internal void OnPurchasesUpdated(IGoogleBillingResult result, List purchases) { if (result.responseCode == GoogleBillingResponseCode.Ok) { @@ -75,23 +78,19 @@ internal void OnPurchasesUpdated(IGoogleBillingResult result, IEnumerable purchases) + void HandleResultOkCases(IGoogleBillingResult result, List purchases) { if (purchases.Any()) { ApplyOnPurchases(purchases, OnPurchaseOk); } - else if (IsLastProrationModeDeferred()) - { - OnDeferredProrationUpgradeDowngradeSubscriptionOk(); - } else { HandleErrorCases(result, purchases); } } - void HandleErrorCases(IGoogleBillingResult billingResult, IEnumerable purchases) + void HandleErrorCases(IGoogleBillingResult billingResult, List purchases) { if (!purchases.Any()) { @@ -99,7 +98,7 @@ void HandleErrorCases(IGoogleBillingResult billingResult, IEnumerable + /// This is C# representation of the Java Class PurchasesResponseListener + /// See more + /// + class GooglePurchasesResponseListener : AndroidJavaProxy + { + const string k_AndroidSkuDetailsResponseListenerClassName = + "com.android.billingclient.api.PurchasesResponseListener"; + + Action> m_OnQueryPurchasesResponse; + + internal GooglePurchasesResponseListener( + Action> onQueryPurchasesResponse) + : base(k_AndroidSkuDetailsResponseListenerClassName) + { + m_OnQueryPurchasesResponse = onQueryPurchasesResponse; + } + + [Preserve] + public void onQueryPurchasesResponse(AndroidJavaObject billingResult, AndroidJavaObject purchases) + { + m_OnQueryPurchasesResponse(new GoogleBillingResult(billingResult), purchases.EnumerateAndWrap()); + } + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchasesResponseListener.cs.meta similarity index 83% rename from Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs.meta rename to Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchasesResponseListener.cs.meta index 3b715c3..f8ce213 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs.meta +++ b/Runtime/Stores/Android/GooglePlay/AAR/Listeners/GooglePurchasesResponseListener.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 73cb99da1b593d4428ac57b7066c458c +guid: 8b4b9e7544a864fa4b0dcfa340dd0b8c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs index 90ee50d..89f2479 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingClient.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using Uniject; -using UnityEngine; using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Utils; namespace UnityEngine.Purchasing.Models { @@ -26,6 +26,13 @@ static AndroidJavaClass GetBillingFlowParamClass() return new AndroidJavaClass(k_AndroidBillingFlowParamClassName); } + const string k_AndroidSubscriptionUpdateParamClassName = "com.android.billingclient.api.BillingFlowParams$SubscriptionUpdateParams"; + + static AndroidJavaClass GetSubscriptionUpdateParamClass() + { + return new AndroidJavaClass(k_AndroidSubscriptionUpdateParamClassName); + } + const string k_AndroidPriceChangeFlowParamClassName = "com.android.billingclient.api.PriceChangeFlowParams"; static AndroidJavaClass GetPriceChangeFlowParamClass() @@ -91,9 +98,15 @@ public bool IsReady() return m_BillingClient.Call("isReady"); } - public AndroidJavaObject QueryPurchase(string skuType) + public GoogleBillingConnectionState GetConnectionState() { - return m_BillingClient.Call("queryPurchases", skuType); + return (GoogleBillingConnectionState)m_BillingClient.Call("getConnectionState"); + } + + public void QueryPurchasesAsync(string skuType, Action> onQueryPurchasesResponse) + { + var listener = new GooglePurchasesResponseListener(onQueryPurchasesResponse); + m_BillingClient.Call("queryPurchasesAsync", skuType, listener); } public void QuerySkuDetailsAsync(List skus, string type, @@ -107,12 +120,12 @@ public void QuerySkuDetailsAsync(List skus, string type, m_BillingClient.Call("querySkuDetailsAsync", skuDetailsParams, listener); } - public AndroidJavaObject LaunchBillingFlow(AndroidJavaObject sku, string oldSku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode) + public AndroidJavaObject LaunchBillingFlow(AndroidJavaObject sku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode) { - return m_BillingClient.Call("launchBillingFlow", UnityActivity.GetCurrentActivity(), MakeBillingFlowParams(sku, oldSku, oldPurchaseToken, prorationMode)); + return m_BillingClient.Call("launchBillingFlow", UnityActivity.GetCurrentActivity(), MakeBillingFlowParams(sku, oldPurchaseToken, prorationMode)); } - AndroidJavaObject MakeBillingFlowParams(AndroidJavaObject sku, string oldSku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode) + AndroidJavaObject MakeBillingFlowParams(AndroidJavaObject sku, string oldPurchaseToken, GooglePlayProrationMode? prorationMode) { AndroidJavaObject billingFlowParams = GetBillingFlowParamClass().CallStatic("newBuilder"); @@ -121,20 +134,27 @@ AndroidJavaObject MakeBillingFlowParams(AndroidJavaObject sku, string oldSku, st billingFlowParams = billingFlowParams.Call("setSkuDetails", sku); - if (oldSku != null && oldPurchaseToken != null) + if (oldPurchaseToken != null && prorationMode != null) { - billingFlowParams = billingFlowParams.Call("setOldSku", oldSku, oldPurchaseToken); - } - - if (prorationMode != null) - { - billingFlowParams = billingFlowParams.Call("setReplaceSkusProrationMode", (int)prorationMode); + var subscriptionUpdateParams = BuildSubscriptionUpdateParams(oldPurchaseToken, prorationMode.Value); + billingFlowParams = billingFlowParams.Call("setSubscriptionUpdateParams", subscriptionUpdateParams); } billingFlowParams = billingFlowParams.Call("build"); return billingFlowParams; } + static AndroidJavaObject BuildSubscriptionUpdateParams(string oldPurchaseToken, GooglePlayProrationMode prorationMode) + { + var subscriptionUpdateParams = GetSubscriptionUpdateParamClass().CallStatic("newBuilder"); + + subscriptionUpdateParams = subscriptionUpdateParams.Call("setReplaceSkusProrationMode", (int)prorationMode); + subscriptionUpdateParams = subscriptionUpdateParams.Call("setOldSkuPurchaseToken", oldPurchaseToken); + + subscriptionUpdateParams = subscriptionUpdateParams.Call("build"); + return subscriptionUpdateParams; + } + AndroidJavaObject SetObfuscatedProfileIdIfNeeded(AndroidJavaObject billingFlowParams) { if (m_ObfuscatedProfileId != null) @@ -155,22 +175,22 @@ AndroidJavaObject SetObfuscatedAccountIdIfNeeded(AndroidJavaObject billingFlowPa return billingFlowParams; } - public void ConsumeAsync(string purchaseToken, ProductDefinition product, GooglePurchase googlePurchase, Action onConsume) + public void ConsumeAsync(string purchaseToken, Action onConsume) { - AndroidJavaObject consumeParams = GetConsumeParamsClass().CallStatic("newBuilder"); + var consumeParams = GetConsumeParamsClass().CallStatic("newBuilder"); consumeParams = consumeParams.Call("setPurchaseToken", purchaseToken); consumeParams = consumeParams.Call("build"); - m_BillingClient.Call("consumeAsync", consumeParams, new GoogleConsumeResponseListener(product, googlePurchase, onConsume)); + m_BillingClient.Call("consumeAsync", consumeParams, new GoogleConsumeResponseListener(onConsume)); } - public void AcknowledgePurchase(string purchaseToken, ProductDefinition product, GooglePurchase googlePurchase, Action onAcknowledge) + public void AcknowledgePurchase(string purchaseToken, Action onAcknowledge) { - AndroidJavaObject acknowledgePurchaseParams = GetAcknowledgePurchaseParamsClass().CallStatic("newBuilder"); + var acknowledgePurchaseParams = GetAcknowledgePurchaseParamsClass().CallStatic("newBuilder"); acknowledgePurchaseParams = acknowledgePurchaseParams.Call("setPurchaseToken", purchaseToken); acknowledgePurchaseParams = acknowledgePurchaseParams.Call("build"); - m_BillingClient.Call("acknowledgePurchase", acknowledgePurchaseParams, new GoogleAcknowledgePurchaseListener(product, googlePurchase, onAcknowledge)); + m_BillingClient.Call("acknowledgePurchase", acknowledgePurchaseParams, new GoogleAcknowledgePurchaseListener(onAcknowledge)); } public void LaunchPriceChangeConfirmationFlow(AndroidJavaObject skuDetails, GooglePriceChangeConfirmationListener listener) diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs new file mode 100644 index 0000000..8c89f1c --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs @@ -0,0 +1,14 @@ +namespace UnityEngine.Purchasing +{ + /// + /// This is C# representation of the Java Class Purchase + /// See more + /// + enum GoogleBillingConnectionState + { + Disconnected = 0, + Connecting = 1, + Connected = 2, + Closed = 3 + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs.meta similarity index 83% rename from Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs.meta rename to Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs.meta index 102fbf7..68f1b74 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GoogleBillingConnectionState.cs.meta +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GoogleBillingConnectionState.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 1253e4c700cf14c09b18172d80ae3146 +guid: b8f7e00065dce4db2837f2034ebe19be MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchase.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchase.cs index 59c1c2c..e9880d5 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchase.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchase.cs @@ -1,3 +1,8 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using UnityEngine.Purchasing.Interfaces; using UnityEngine.Purchasing.Utils; namespace UnityEngine.Purchasing.Models @@ -6,38 +11,35 @@ namespace UnityEngine.Purchasing.Models /// This is C# representation of the Java Class Purchase /// See more ///
- class GooglePurchase + class GooglePurchase : IGooglePurchase { - public IAndroidJavaObjectWrapper javaPurchase; - public int purchaseState; - public string sku; - public string orderId; - public string receipt; - public string signature; - public string originalJson; - public string purchaseToken; + public IAndroidJavaObjectWrapper javaPurchase { get; } + public int purchaseState { get; } + public List skus { get; } + public string orderId { get; } + public string receipt { get; } + public string signature { get; } + public string originalJson { get; } + public string purchaseToken { get; } - internal GooglePurchase() { } + public string? sku => skus.FirstOrDefault(); - internal GooglePurchase(IAndroidJavaObjectWrapper purchase, AndroidJavaObject skuDetails) + internal GooglePurchase(IAndroidJavaObjectWrapper purchase, IEnumerable skuDetails) { - if (purchase != null) - { - javaPurchase = purchase; - purchaseState = purchase.Call("getPurchaseState"); - sku = purchase.Call("getSku"); - orderId = purchase.Call("getOrderId"); - originalJson = purchase.Call("getOriginalJson"); - signature = purchase.Call("getSignature"); - purchaseToken = purchase.Call("getPurchaseToken"); - string encodedReceipt = GoogleReceiptEncoder.EncodeReceipt( - purchaseToken, - originalJson, - signature, - skuDetails.Call("getOriginalJson") - ); - receipt = encodedReceipt; - } + javaPurchase = purchase; + purchaseState = purchase.Call("getPurchaseState"); + skus = purchase.Call("getSkus").Enumerate().ToList(); + orderId = purchase.Call("getOrderId"); + originalJson = purchase.Call("getOriginalJson"); + signature = purchase.Call("getSignature"); + purchaseToken = purchase.Call("getPurchaseToken"); + + var skuDetailsJson = skuDetails.Select(skuDetail => skuDetail.Call("getOriginalJson")).ToList(); + receipt = GoogleReceiptEncoder.EncodeReceipt( + originalJson, + signature, + skuDetailsJson + ); } public virtual bool IsAcknowledged() diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs b/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs deleted file mode 100644 index da2eb41..0000000 --- a/Runtime/Stores/Android/GooglePlay/AAR/Models/GooglePurchaseResult.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using UnityEngine.Purchasing.Utils; - -namespace UnityEngine.Purchasing.Models -{ - /// - /// This is C# representation of the Java Class PurchasesResult - /// See more - /// - class GooglePurchaseResult - { - internal GoogleBillingResponseCode m_ResponseCode; - internal List m_Purchases = new List(); - - internal GooglePurchaseResult(AndroidJavaObject purchaseResult, IGoogleCachedQuerySkuDetailsService cachedQuerySkuDetailsService) - { - m_ResponseCode = (GoogleBillingResponseCode)purchaseResult.Call("getResponseCode"); - FillPurchases(purchaseResult, cachedQuerySkuDetailsService); - } - - void FillPurchases(AndroidJavaObject purchaseResult, IGoogleCachedQuerySkuDetailsService cachedQuerySkuDetailsService) - { - AndroidJavaObject purchaseList = purchaseResult.Call("getPurchasesList"); - - var purchases = purchaseList.EnumerateAndWrap().ToList(); - for (var index = 0; index < purchases.Count; index++) - { - var purchase = purchases[index]; - if (purchase != null) - { - m_Purchases.Add(GooglePurchaseHelper.MakeGooglePurchase(cachedQuerySkuDetailsService.GetCachedQueriedSkus().ToList(), purchase)); - } - else - { - Debug.LogWarning("Failed to retrieve Purchase from Purchase List at index " + index + " of " + purchases.Count + ". FillPurchases will skip this item"); - } - } - } - } -} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/QuerySkuDetailsService.cs b/Runtime/Stores/Android/GooglePlay/AAR/QuerySkuDetailsService.cs index 18b67a9..815e35c 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/QuerySkuDetailsService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/QuerySkuDetailsService.cs @@ -52,7 +52,7 @@ public void QueryAsyncSku(ReadOnlyCollection products, Action void OnActionRetry() { - m_GoogleProductCallback.NotifyQueryProductDetailsFailed(retryCount++); + m_GoogleProductCallback.NotifyQueryProductDetailsFailed(++retryCount); } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs new file mode 100644 index 0000000..e10b555 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Models; + +namespace UnityEngine.Purchasing.Utils +{ + class GooglePurchaseBuilder : IGooglePurchaseBuilder + { + IGoogleCachedQuerySkuDetailsService m_CachedQuerySkuDetailsService; + ILogger m_Logger; + + public GooglePurchaseBuilder(IGoogleCachedQuerySkuDetailsService cachedQuerySkuDetailsService, ILogger logger) + { + m_CachedQuerySkuDetailsService = cachedQuerySkuDetailsService; + m_Logger = logger; + } + + public IEnumerable BuildPurchases(IEnumerable purchases) + { + return purchases.Select(BuildPurchase) + .IgnoreExceptions(LogWarningForException); + } + + void LogWarningForException(Exception exception) + { + m_Logger.LogIAPWarning(exception.Message); + } + + public IGooglePurchase BuildPurchase(IAndroidJavaObjectWrapper purchase) + { + var cachedSkuDetails = m_CachedQuerySkuDetailsService.GetCachedQueriedSkus().Wrap(); + var purchaseSkus = purchase.Call("getSkus").Enumerate(); + + try + { + var skuDetails = TryFindAllSkuDetails(purchaseSkus, cachedSkuDetails); + return new GooglePurchase(purchase, skuDetails); + } + catch (InvalidOperationException) + { + var transactionId = purchase.Call("getPurchaseToken"); + throw new ArgumentException($"Unable to process purchase with transaction id: {transactionId} because the product details associated with the purchased products were not found."); + } + } + + static IEnumerable TryFindAllSkuDetails(IEnumerable skus, IEnumerable skuDetails) + { + return skus.Select(sku => skuDetails.First( + skuDetail => sku == skuDetail.Call("getSku"))); + } + } +} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs.meta b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs.meta new file mode 100644 index 0000000..c67c22b --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93935a767c9464c3b8a85b28b562429e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs deleted file mode 100644 index 059a7dd..0000000 --- a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GooglePurchaseHelper.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine.Purchasing.Models; - -namespace UnityEngine.Purchasing.Utils -{ - static class GooglePurchaseHelper - { - internal static GooglePurchase MakeGooglePurchase(IEnumerable skuDetails, IAndroidJavaObjectWrapper purchase) - { - var sku = purchase.Call("getSku"); - var skuDetail = skuDetails.FirstOrDefault(skuDetailJavaObject => - { - var skuDetailsSku = skuDetailJavaObject.Call("getSku"); - return sku == skuDetailsSku; - }); - return skuDetail != null ? new GooglePurchase(purchase, skuDetail) : null; - } - } -} diff --git a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs index 4113e4c..ae9aa90 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/Utils/GoogleReceiptEncoder.cs @@ -4,18 +4,13 @@ namespace UnityEngine.Purchasing.Utils { static class GoogleReceiptEncoder { - internal static string EncodeReceipt(string transactionId, string purchaseOriginalJson, string purchaseSignature, string skuDetailsJson) - { - return FormatPayload(purchaseOriginalJson, purchaseSignature, skuDetailsJson); - } - - static string FormatPayload(string json, string signature, string skuDetails) + internal static string EncodeReceipt(string purchaseOriginalJson, string purchaseSignature, List skuDetailsJson) { var dic = new Dictionary { - ["json"] = json, - ["signature"] = signature, - ["skuDetails"] = skuDetails + ["json"] = purchaseOriginalJson, + ["signature"] = purchaseSignature, + ["skuDetails"] = skuDetailsJson, }; return MiniJson.JsonEncode(dic); } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs index d89b012..1a2bf8c 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayConfiguration.cs @@ -3,6 +3,7 @@ using System; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing { @@ -64,9 +65,9 @@ public void SetDeferredPurchaseListener(Action action) m_DeferredPurchaseAction = action; } - public void NotifyDeferredProrationUpgradeDowngradeSubscription(IStoreCallback storeCallback, string productId) + public void NotifyDeferredProrationUpgradeDowngradeSubscription(IStoreCallback? storeCallback, string productId) { - Product product = storeCallback.FindProductById(productId); + var product = storeCallback.FindProductById(productId); if (product != null) { m_DeferredProrationUpgradeDowngradeSubscriptionAction?.Invoke(product); @@ -78,9 +79,9 @@ public bool IsFetchPurchasesAtInitializeSkipped() return !m_FetchPurchasesAtInitialize; } - public void NotifyDeferredPurchase(IStoreCallback storeCallback, string productId, string receipt, string transactionId) + public void NotifyDeferredPurchase(IStoreCallback? storeCallback, IGooglePurchase? purchase, string? receipt, string? transactionId) { - Product product = storeCallback.FindProductById(productId); + var product = storeCallback?.FindProductById(purchase?.sku); if (product != null) { ProductPurchaseUpdater.UpdateProductReceiptAndTransactionID(product, receipt, transactionId, GooglePlay.Name); @@ -113,7 +114,7 @@ public void SetObfuscatedAccountId(string accountId) /// For more information please visit https://developer.android.com/google/play/billing/security ///
/// The obfuscated profile id - public void SetObfuscatedProfileId(string profileId) + public void SetObfuscatedProfileId(string? profileId) { m_GooglePlayStoreService.SetObfuscatedProfileId(profileId); } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs index 959466e..c8ef400 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayPurchaseCallback.cs @@ -1,3 +1,6 @@ +#nullable enable + +using Uniject; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; @@ -5,8 +8,14 @@ namespace UnityEngine.Purchasing { class GooglePlayPurchaseCallback : IGooglePurchaseCallback { - IStoreCallback m_StoreCallback; - IGooglePlayConfigurationInternal m_GooglePlayConfigurationInternal; + IStoreCallback? m_StoreCallback; + IGooglePlayConfigurationInternal? m_GooglePlayConfigurationInternal; + IUtil m_Util; + + public GooglePlayPurchaseCallback(IUtil util) + { + m_Util = util; + } public void SetStoreCallback(IStoreCallback storeCallback) { @@ -18,9 +27,9 @@ public void SetStoreConfiguration(IGooglePlayConfigurationInternal configuration m_GooglePlayConfigurationInternal = configuration; } - public void OnPurchaseSuccessful(string sku, string receipt, string purchaseToken) + public void OnPurchaseSuccessful(IGooglePurchase purchase, string receipt, string purchaseToken) { - m_StoreCallback?.OnPurchaseSucceeded(sku, receipt, purchaseToken); + m_StoreCallback?.OnPurchaseSucceeded(purchase.sku, receipt, purchaseToken); } public void OnPurchaseFailed(PurchaseFailureDescription purchaseFailureDescription) @@ -28,14 +37,19 @@ public void OnPurchaseFailed(PurchaseFailureDescription purchaseFailureDescripti m_StoreCallback?.OnPurchaseFailed(purchaseFailureDescription); } - public void NotifyDeferredPurchase(string sku, string receipt, string purchaseToken) + public void NotifyDeferredPurchase(IGooglePurchase purchase, string receipt, string purchaseToken) { - m_GooglePlayConfigurationInternal?.NotifyDeferredPurchase(m_StoreCallback, sku, receipt, purchaseToken); + m_Util.RunOnMainThread(() => + m_GooglePlayConfigurationInternal?.NotifyDeferredPurchase(m_StoreCallback, purchase, receipt, + purchaseToken)); + } public void NotifyDeferredProrationUpgradeDowngradeSubscription(string sku) { - m_GooglePlayConfigurationInternal?.NotifyDeferredProrationUpgradeDowngradeSubscription(m_StoreCallback, sku); + m_Util.RunOnMainThread(() => + m_GooglePlayConfigurationInternal?.NotifyDeferredProrationUpgradeDowngradeSubscription(m_StoreCallback, + sku)); } } } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs index 1590f79..5f9e5cc 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStore.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.ObjectModel; using Uniject; using UnityEngine.Purchasing.Extension; diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs index deafa8e..486ca4a 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs @@ -37,12 +37,8 @@ public virtual void UpgradeDowngradeSubscription(string oldSku, string newSku, G { Product product = m_StoreCallback.FindProductById(newSku); Product oldProduct = m_StoreCallback.FindProductById(oldSku); - if (product != null && product.definition.type == ProductType.Subscription && - oldProduct != null && oldProduct.definition.type == ProductType.Subscription) - { - m_GooglePlayStoreService.Purchase(product.definition, oldProduct, desiredProrationMode); - } - else + if (product == null || product.definition.type != ProductType.Subscription || + oldProduct == null || oldProduct.definition.type != ProductType.Subscription) { m_StoreCallback?.OnPurchaseFailed( new PurchaseFailureDescription( @@ -50,34 +46,25 @@ public virtual void UpgradeDowngradeSubscription(string oldSku, string newSku, G PurchaseFailureReason.ProductUnavailable, "Please verify that the products are subscriptions and are not null.")); } - } - - public virtual void RestoreTransactions(Action callback) - { - m_GooglePlayStoreService.FetchPurchases(purchase => - { - if (purchase != null) - { - callback(true); - } - }); - } - - public void FinishAdditionalTransaction(string productId, string transactionId) - { - Product product = m_StoreCallback.FindProductById(productId); - if (product != null && transactionId != null) + else if (string.IsNullOrEmpty(oldProduct.transactionID)) { - m_GooglePlayStoreFinishTransactionService.FinishTransaction(product.definition, transactionId); + m_StoreCallback?.OnPurchaseFailed( + new PurchaseFailureDescription( + newSku ?? "", + PurchaseFailureReason.ProductUnavailable, + "Invalid transaction id for old product: " + oldProduct.definition.id)); } else { - m_StoreCallback?.OnPurchaseFailed( - new PurchaseFailureDescription(productId ?? "", PurchaseFailureReason.ProductUnavailable, - "Please make the product id and transaction id is not null")); + m_GooglePlayStoreService.Purchase(product.definition, oldProduct, desiredProrationMode); } } + public virtual void RestoreTransactions(Action callback) + { + m_GooglePlayStoreService.FetchPurchases(_ => { callback(true); }); + } + public void ConfirmSubscriptionPriceChange(string productId, Action callback) { Product product = m_StoreCallback.FindProductById(productId); diff --git a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs index 52189c0..9caddbf 100644 --- a/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs +++ b/Runtime/Stores/Android/GooglePlay/Interfaces/IGooglePlayConfigurationInternal.cs @@ -1,15 +1,16 @@ #nullable enable -using System; using UnityEngine.Purchasing.Extension; +using UnityEngine.Purchasing.Interfaces; +using UnityEngine.Purchasing.Models; namespace UnityEngine.Purchasing { interface IGooglePlayConfigurationInternal { void NotifyInitializationConnectionFailed(); - void NotifyDeferredPurchase(IStoreCallback storeCallback, string productId, string receipt, string transactionId); - void NotifyDeferredProrationUpgradeDowngradeSubscription(IStoreCallback storeCallback, string productId); + void NotifyDeferredPurchase(IStoreCallback? storeCallback, IGooglePurchase purchase, string receipt, string transactionId); + void NotifyDeferredProrationUpgradeDowngradeSubscription(IStoreCallback? storeCallback, string productId); bool IsFetchPurchasesAtInitializeSkipped(); void NotifyQueryProductDetailsFailed(int retryCount); } diff --git a/Runtime/Stores/Android/GooglePlay/Services/GoogleFetchPurchases.cs b/Runtime/Stores/Android/GooglePlay/Services/GoogleFetchPurchases.cs index 6aa4102..026e401 100644 --- a/Runtime/Stores/Android/GooglePlay/Services/GoogleFetchPurchases.cs +++ b/Runtime/Stores/Android/GooglePlay/Services/GoogleFetchPurchases.cs @@ -10,12 +10,11 @@ namespace UnityEngine.Purchasing class GoogleFetchPurchases : IGoogleFetchPurchases { IGooglePlayStoreService m_GooglePlayStoreService; - IGooglePlayStoreFinishTransactionService m_TransactionService; IStoreCallback m_StoreCallback; - internal GoogleFetchPurchases(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService transactionService) + + internal GoogleFetchPurchases(IGooglePlayStoreService googlePlayStoreService) { m_GooglePlayStoreService = googlePlayStoreService; - m_TransactionService = transactionService; } public void SetStoreCallback(IStoreCallback storeCallback) @@ -37,48 +36,31 @@ public void FetchPurchases(Action> onQueryPurchaseSucceed) }); } - List FillProductsWithPurchases(IEnumerable purchases) + List FillProductsWithPurchases(IEnumerable purchases) { - var purchasedProducts = new List(); - - foreach (var purchase in purchases.Where(purchase => purchase != null).ToList()) - { - var product = m_StoreCallback?.FindProductById(purchase.sku); - if (product != null) - { - var updatedProduct = new Product(product.definition, product.metadata, purchase.receipt) - { - transactionID = purchase.purchaseToken - }; - purchasedProducts.Add(updatedProduct); - } - } + return purchases.SelectMany(BuildProductsFromPurchase).ToList(); + } - return purchasedProducts; + IEnumerable BuildProductsFromPurchase(IGooglePurchase purchase) + { + var products = purchase?.skus?.Select(sku => m_StoreCallback?.FindProductById(sku)).NonNull(); + return products?.Select(product => CompleteProductInfoWithPurchase(product, purchase)); } - void OnFetchedPurchase(List purchases) + static Product CompleteProductInfoWithPurchase(Product product, IGooglePurchase purchase) { - if (purchases != null) + return new Product(product.definition, product.metadata, purchase.receipt) { - var purchasedProducts = FillProductsWithPurchases(purchases); - if (purchasedProducts.Count > 0) - { - m_StoreCallback?.OnAllPurchasesRetrieved(purchasedProducts); - } - } + transactionID = purchase.purchaseToken, + }; } - void FinishTransaction(GooglePurchase purchase) + void OnFetchedPurchase(List purchases) { - Product product = m_StoreCallback.FindProductById(purchase.sku); - if (product != null) - { - m_TransactionService.FinishTransaction(product.definition, purchase.purchaseToken); - } - else + var purchasedProducts = FillProductsWithPurchases(purchases); + if (purchasedProducts.Any()) { - m_StoreCallback.OnPurchaseFailed(new PurchaseFailureDescription(purchase.sku, PurchaseFailureReason.ProductUnavailable, "Product was not found but was purchased")); + m_StoreCallback?.OnAllPurchasesRetrieved(purchasedProducts); } } } diff --git a/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreFinishTransactionService.cs b/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreFinishTransactionService.cs index 616764e..f1cab76 100644 --- a/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreFinishTransactionService.cs +++ b/Runtime/Stores/Android/GooglePlay/Services/GooglePlayStoreFinishTransactionService.cs @@ -1,3 +1,5 @@ +#nullable enable + using System.Collections.Generic; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; @@ -9,51 +11,43 @@ class GooglePlayStoreFinishTransactionService : IGooglePlayStoreFinishTransactio { HashSet m_ProcessedPurchaseToken; IGooglePlayStoreService m_GooglePlayStoreService; - IStoreCallback m_StoreCallback; + IStoreCallback? m_StoreCallback; + internal GooglePlayStoreFinishTransactionService(IGooglePlayStoreService googlePlayStoreService) { m_ProcessedPurchaseToken = new HashSet(); m_GooglePlayStoreService = googlePlayStoreService; } - public void SetStoreCallback(IStoreCallback storeCallback) + public void SetStoreCallback(IStoreCallback? storeCallback) { m_StoreCallback = storeCallback; } - public void FinishTransaction(ProductDefinition product, string purchaseToken) - { - m_GooglePlayStoreService.FinishTransaction(product, purchaseToken, OnConsume, OnAcknowledge); - } - - public void OnConsume(ProductDefinition product, GooglePurchase googlePurchase, IGoogleBillingResult billingResult, string purchaseToken) - { - HandleFinishTransaction(product, googlePurchase, billingResult, purchaseToken); - } - - public void OnAcknowledge(ProductDefinition product, GooglePurchase googlePurchase, IGoogleBillingResult billingResult) + public void FinishTransaction(ProductDefinition? product, string? purchaseToken) { - HandleFinishTransaction(product, googlePurchase, billingResult, googlePurchase.purchaseToken); + m_GooglePlayStoreService.FinishTransaction(product, purchaseToken, + (billingResult, googlePurchase) => HandleFinishTransaction(product, billingResult, googlePurchase)); } - public void HandleFinishTransaction(ProductDefinition product, GooglePurchase googlePurchase, IGoogleBillingResult billingResult, string purchaseToken) + void HandleFinishTransaction(ProductDefinition? product, IGoogleBillingResult billingResult, IGooglePurchase purchase) { - if (!m_ProcessedPurchaseToken.Contains(purchaseToken)) + if (!m_ProcessedPurchaseToken.Contains(purchase.purchaseToken)) { if (billingResult.responseCode == GoogleBillingResponseCode.Ok) { - m_ProcessedPurchaseToken.Add(purchaseToken); - CallPurchaseSucceededUpdateReceipt(product, googlePurchase, purchaseToken); + m_ProcessedPurchaseToken.Add(purchase.purchaseToken); + CallPurchaseSucceededUpdateReceipt(purchase); } else if (IsResponseCodeInRecoverableState(billingResult)) { - FinishTransaction(product, purchaseToken); + FinishTransaction(product, purchase.purchaseToken); } else { m_StoreCallback?.OnPurchaseFailed( new PurchaseFailureDescription( - product.storeSpecificId, + product?.storeSpecificId, PurchaseFailureReason.Unknown, billingResult.debugMessage + " {code: " + billingResult.responseCode + ", M: GPSFTS.HFT}" ) @@ -62,12 +56,12 @@ public void HandleFinishTransaction(ProductDefinition product, GooglePurchase go } } - void CallPurchaseSucceededUpdateReceipt(ProductDefinition product, GooglePurchase googlePurchase, string purchaseToken) + void CallPurchaseSucceededUpdateReceipt(IGooglePurchase googlePurchase) { m_StoreCallback?.OnPurchaseSucceeded( - product.storeSpecificId, + googlePurchase.sku, googlePurchase.receipt, - purchaseToken + googlePurchase.purchaseToken ); } @@ -77,8 +71,8 @@ static bool IsResponseCodeInRecoverableState(IGoogleBillingResult billingResult) // https://github.com/android/play-billing-samples/issues/337 // usually works like a charm next acknowledge return billingResult.responseCode == GoogleBillingResponseCode.ServiceUnavailable || - billingResult.responseCode == GoogleBillingResponseCode.DeveloperError || - billingResult.responseCode == GoogleBillingResponseCode.FatalError; + billingResult.responseCode == GoogleBillingResponseCode.DeveloperError || + billingResult.responseCode == GoogleBillingResponseCode.FatalError; } } } diff --git a/Runtime/Stores/Android/GooglePlay/package.json b/Runtime/Stores/Android/GooglePlay/package.json new file mode 100644 index 0000000..ebe4eb6 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/package.json @@ -0,0 +1,7 @@ +{ + "name": "GooglePlay", + "version": "1.0.0", + "dependencies": { + + } +} diff --git a/Runtime/Stores/Android/GooglePlay/package.json.meta b/Runtime/Stores/Android/GooglePlay/package.json.meta new file mode 100644 index 0000000..45d7320 --- /dev/null +++ b/Runtime/Stores/Android/GooglePlay/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: aee966c2a3479f841ae8ef29d0cfa262 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index 6050416..7e98524 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -25,7 +25,7 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS /// [Obsolete("Not accurate. Use Version instead.", false)] public const string k_PackageVersion = "3.0.1"; - internal readonly string k_Version = "4.3.0"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. + internal readonly string k_Version = "4.4.0"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. /// /// The version of com.unity.purchasing installed and the app was built using. /// @@ -256,14 +256,14 @@ private IStore InstantiateAndroid() private IStore InstantiateGoogleStore() { - IGooglePurchaseCallback googlePurchaseCallback = new GooglePlayPurchaseCallback(); + IGooglePurchaseCallback googlePurchaseCallback = new GooglePlayPurchaseCallback(util); IGoogleProductCallback googleProductCallback = new GooglePlayProductCallback(); var googlePlayStoreService = BuildGooglePlayStoreServiceAar(googlePurchaseCallback, googleProductCallback); IGooglePlayStorePurchaseService googlePlayStorePurchaseService = new GooglePlayStorePurchaseService(googlePlayStoreService); IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService = new GooglePlayStoreFinishTransactionService(googlePlayStoreService); - IGoogleFetchPurchases googleFetchPurchases = new GoogleFetchPurchases(googlePlayStoreService, googlePlayStoreFinishTransactionService); + IGoogleFetchPurchases googleFetchPurchases = new GoogleFetchPurchases(googlePlayStoreService); var googlePlayConfiguration = BuildGooglePlayStoreConfiguration(googlePlayStoreService, googlePurchaseCallback, googleProductCallback); var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); @@ -317,13 +317,16 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g var googleCachedQuerySkuDetailsService = new GoogleCachedQuerySkuDetailsService(); var googleLastKnownProductService = new GoogleLastKnownProductService(); var googlePurchaseStateEnumProvider = new GooglePurchaseStateEnumProvider(); - var googlePurchaseUpdatedListener = new GooglePurchaseUpdatedListener(googleLastKnownProductService, googlePurchaseCallback, googleCachedQuerySkuDetailsService, googlePurchaseStateEnumProvider); + var googlePurchaseBuilder = new GooglePurchaseBuilder(googleCachedQuerySkuDetailsService, logger); + var googlePurchaseUpdatedListener = new GooglePurchaseUpdatedListener(googleLastKnownProductService, + googlePurchaseCallback, googlePurchaseBuilder, googleCachedQuerySkuDetailsService, + googlePurchaseStateEnumProvider); var googleBillingClient = new GoogleBillingClient(googlePurchaseUpdatedListener, util); var skuDetailsConverter = new SkuDetailsConverter(); var retryPolicy = new ExponentialRetryPolicy(); var googleQuerySkuDetailsService = new QuerySkuDetailsService(googleBillingClient, googleCachedQuerySkuDetailsService, skuDetailsConverter, retryPolicy, googleProductCallback); var purchaseService = new GooglePurchaseService(googleBillingClient, googlePurchaseCallback, googleQuerySkuDetailsService); - var queryPurchasesService = new GoogleQueryPurchasesService(googleBillingClient, googleCachedQuerySkuDetailsService); + var queryPurchasesService = new GoogleQueryPurchasesService(googleBillingClient, googlePurchaseBuilder); var finishTransactionService = new GoogleFinishTransactionService(googleBillingClient, queryPurchasesService); var billingClientStateListener = new BillingClientStateListener(); var priceChangeService = new GooglePriceChangeService(googleBillingClient, googleQuerySkuDetailsService); diff --git a/Runtime/Stores/SubscriptionManager.cs b/Runtime/Stores/SubscriptionManager.cs index 7fac02b..e77cd5c 100644 --- a/Runtime/Stores/SubscriptionManager.cs +++ b/Runtime/Stores/SubscriptionManager.cs @@ -2,6 +2,7 @@ using System.Xml; using System.Reflection; using System.Collections.Generic; +using System.Linq; using UnityEngine.Purchasing; using UnityEngine.Purchasing.Security; using UnityEngine; @@ -287,10 +288,9 @@ private AppleInAppPurchaseReceipt findMostRecentReceipt(List)MiniJson.JsonDecode(payload); - var validSkuDetailsKey = payload_wrapper.TryGetValue("skuDetails", out var skuDetailsObject); + payload_wrapper.TryGetValue("skuDetails", out var skuDetailsObject); - string skuDetails = null; - if (validSkuDetailsKey) skuDetails = skuDetailsObject as string; + var skuDetails = (skuDetailsObject as List)?.Select(obj => obj as string); var purchaseHistorySupported = false; @@ -351,11 +351,12 @@ private SubscriptionInfo getGooglePlayStoreSubInfo(string payload) } } - return new SubscriptionInfo(skuDetails, isAutoRenewing, purchaseDate, isFreeTrial, hasIntroductoryPrice, - purchaseHistorySupported, updateMetadata); - } + var skuDetail = skuDetails.First(); + return new SubscriptionInfo(skuDetail, isAutoRenewing, purchaseDate, isFreeTrial, hasIntroductoryPrice, + purchaseHistorySupported, updateMetadata); + } } /// diff --git a/Runtime/Stores/Util/EnumerableExtensions.cs b/Runtime/Stores/Util/EnumerableExtensions.cs new file mode 100644 index 0000000..b3777c0 --- /dev/null +++ b/Runtime/Stores/Util/EnumerableExtensions.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UnityEngine.Purchasing +{ + static class EnumerableExtensions + { + public static IEnumerable NonNull(this IEnumerable enumerable) + { + return enumerable.Where(obj => obj != null); + } + + public static IEnumerable IgnoreExceptions(this IEnumerable enumerable, + Action onException = null) where TException : Exception + { + using var enumerator = enumerable.GetEnumerator(); + + var hasNext = true; + + while (hasNext) + { + try + { + hasNext = enumerator.MoveNext(); + } + catch (TException ex) + { + onException?.Invoke(ex); + continue; + } + + if (hasNext) + { + yield return enumerator.Current; + } + } + } + } +} diff --git a/Runtime/Stores/Util/EnumerableExtensions.cs.meta b/Runtime/Stores/Util/EnumerableExtensions.cs.meta new file mode 100644 index 0000000..348d7b8 --- /dev/null +++ b/Runtime/Stores/Util/EnumerableExtensions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 750fd93fb122946179a2528fd9214279 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/ValidationExceptions.json b/ValidationExceptions.json deleted file mode 100644 index 130f943..0000000 --- a/ValidationExceptions.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "ErrorExceptions": [ - { - "ValidationTest": "API Validation", - "ExceptionMessage": "Additions require a new minor or major version.", - "PackageVersion": "4.2.1" - } - ], - "WarningExceptions": [] -} diff --git a/ValidationExceptions.json.meta b/ValidationExceptions.json.meta deleted file mode 100644 index 1488339..0000000 --- a/ValidationExceptions.json.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: feea2df7ea874d17a985548753a89e50 -timeCreated: 1655327261 \ No newline at end of file diff --git a/package.json b/package.json index 91d9349..5da13e3 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "Android", "iOS" ], - "changelog": "### Added\n- GooglePlay - API `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action)` called when Unity IAP fails to query product details. The `Action` will be called on each query product details failure with the retry count. See documentation \"Store Guides\" > \"Google Play\" for a sample usage." + "changelog": "### Added\n- GooglePlay - Google Play Billing Library version 4.0.0.\n - The Multi-quantity feature is not yet supported by the IAP package and will come in a future update. **Do not enable `Multi-quantity` in the Google Play Console.**\n - Add support for\n the [IMMEDIATE_AND_CHARGE_FULL_PRICE](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode#IMMEDIATE_AND_CHARGE_FULL_PRICE)\n proration mode. Use `GooglePlayProrationMode.ImmediateAndChargeFullPrice` for easy access.\n\n### Fixed\n- GooglePlay - Fix `IGooglePlayConfiguration.SetDeferredPurchaseListener`\n and `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` callbacks sometimes not being\n called from the main thread.\n- GooglePlay - When configuring `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action retryCount)`, the action will be invoked with retryCount starting at 1 instead of 0.\n- GooglePlay - Added a validation when upgrading/downgrading a subscription that calls `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.ProductUnavailable` when the old transaction id is empty or null. This can occur when attempting to upgrade/downgrade a subscription that the user doesn't own." }, - "version": "4.3.0", + "version": "4.4.0", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -34,15 +34,15 @@ "license": "Unity Companion Package License v1.0", "hideInEditor": false, "relatedPackages": { - "com.unity.purchasing.tests": "4.3.0" + "com.unity.purchasing.tests": "4.4.0" }, "upmCi": { - "footprint": "0ce80874c9229d35d310a72c5334ca2ee6617c75" + "footprint": "96eed2b3c93a18c9512177b87bbc7c736957dec9" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "22afb8218d24e44d7eaa04f57aed2bebe79b82e8" + "revision": "94c5a447a90fc84222a89d4cffe216b3cfba7ef6" }, "samples": [ { From 8db71d2808fb991d1cea16e8a76e5f36a57f6854 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Thu, 11 Aug 2022 00:00:00 +0000 Subject: [PATCH 06/12] com.unity.purchasing@4.4.1 ## [4.4.1] - 2022-08-11 ### Fixed - GooglePlay - Fixed NullReferenceException and ArgumentException that would rarely occur due to a concurrency issue introduced in Unity IAP 4.2.0 - Amazon - Set android:export to true to support Android API level 31+ --- CHANGELOG.md | 5 +++ .../Android/AmazonAppStore.aar | Bin 109243 -> 109254 bytes .../Contents/MacOS/unitypurchasing | Bin 296720 -> 296720 bytes .../Diagnostics/TelemetryDiagnosticNames.cs | 3 +- .../Purchasing/Telemetry/TelemetryQueue.cs | 11 +++-- .../GooglePlay/AAR/GooglePlayStoreService.cs | 39 ++++++++++++++---- .../AAR/MetricizedGooglePlayStoreService.cs | 14 +++++-- .../GooglePlay/GooglePlayStoreExtensions.cs | 11 ++++- .../MetricizedGooglePlayStoreExtensions.cs | 4 +- Runtime/Stores/StandardPurchasingModule.cs | 8 +++- package.json | 11 +++-- 11 files changed, 75 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de197b1..82e544c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [4.4.1] - 2022-08-11 +### Fixed +- GooglePlay - Fixed NullReferenceException and ArgumentException that would rarely occur due to a concurrency issue introduced in Unity IAP 4.2.0 +- Amazon - Set android:export to true to support Android API level 31+ + ## [4.4.0] - 2022-07-11 ### Added - GooglePlay - Google Play Billing Library version 4.0.0. diff --git a/Plugins/UnityPurchasing/Android/AmazonAppStore.aar b/Plugins/UnityPurchasing/Android/AmazonAppStore.aar index 413310ae765bac4faea30e0d374a6a4e3a1a93cb..a0616fde6a19c21d442be7a877879ad04f329b13 100644 GIT binary patch delta 412 zcmV;N0b~BV)dt4Z2C!%W2`o}MCq4lH0N0ao0V{tvpd$4WP^pkOTK2YGrGChEs>0Xf z=Fuil$Z>XdW_BF@c+!Xb2tJ@S1)0-3l7o>}q1hFriQCydc`P%og~2U^m?ydk3t^PE zs0y-=(JdGYat~Sr+T&DO&4jC&hroJ_WO@3WkhR!BK@x*D2ID7njhA~7k|7mZd|A^m z`?Y^XpY6%2v^BputAvI=dzrNa-UJAb0T`wUEK)fqJ^=s#*O%X@0a5|UmoljV zH~~+WYpDS-0dtp~sR1|wo>G&qQWux*sR1ehESDLo0WtwmmrSYwP60iao2mgC24tuK G0001?e7p_- delta 421 zcmV;W0b2gX)dsuO2C!%W2~`|~?K1%Y0JD>E0V{ucKt<{$pi&`mwCrtbrCwXMQvqL( zn@5{K!N=Lznc0!~^1~eS6ZoJl7GzHENDe5i(zq{36L+(FvMe)h1nM1xm?t`hg+S%4 zRs}i4=oX9xc>p5i9`rUq0EsR2R(A(teo0WtzcQ#n51RX>jo=i85_x5@nV< z05(tgDswEvH+LUfd&u)H-@eqE09Wc+Jei9bf1y>Ha~f zc)O$^(@ACp7E_b5hZmHWEGRfzU(9JL6_qrd`B$Fq$zGxMb#sLe|88dzW!lap$}D#P zY@YH}=2(cy&DWUuQblIJOq~7g(q`G)KMq*FiV**G%keJT_NCS=H<=agY))W|5)4b) jWFTJ`yy(lD>H4M-$2LyUt+>2FDR;$E?(OsJS&G~NnmJC8 diff --git a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs index 2c25241..bfa0dba 100644 --- a/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs +++ b/Runtime/Purchasing/Telemetry/Diagnostics/TelemetryDiagnosticNames.cs @@ -2,7 +2,8 @@ namespace UnityEngine.Purchasing.Telemetry { static class TelemetryDiagnosticNames { - internal const string ParseReceiptTransactionError = "parse_receipt_transaction_error"; + internal const string FetchPurchasesError = "fetch_purchases_error"; internal const string InvalidProductError = "invalid_product_error"; + internal const string ParseReceiptTransactionError = "parse_receipt_transaction_error"; } } diff --git a/Runtime/Purchasing/Telemetry/TelemetryQueue.cs b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs index af9b36f..a904576 100644 --- a/Runtime/Purchasing/Telemetry/TelemetryQueue.cs +++ b/Runtime/Purchasing/Telemetry/TelemetryQueue.cs @@ -1,12 +1,12 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; namespace UnityEngine.Purchasing.Telemetry { class TelemetryQueue { Action m_SendTelemetryEvent; - Queue m_Queue; + ConcurrentQueue m_Queue; internal const int k_maxQueueSize = 10; public TelemetryQueue(Action sendTelemetryEvent) @@ -16,12 +16,12 @@ public TelemetryQueue(Action sendTelemetryEvent) internal void QueueEvent(TTelemetryEventParams telemetryEvent) { - m_Queue ??= new Queue(); + m_Queue ??= new ConcurrentQueue(); m_Queue.Enqueue(telemetryEvent); if (m_Queue.Count > k_maxQueueSize) { - m_Queue.Dequeue(); + m_Queue.TryDequeue(out _); } } @@ -32,11 +32,10 @@ internal void SendQueuedEvents() return; } - foreach (var telemetryEvent in m_Queue) + while (m_Queue.TryDequeue(out var telemetryEvent)) { m_SendTelemetryEvent(telemetryEvent); } - m_Queue.Clear(); } } } diff --git a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs index efdae37..5ca1beb 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/GooglePlayStoreService.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq.Expressions; using System.Threading.Tasks; using UnityEngine.Purchasing.Extension; using UnityEngine.Purchasing.Interfaces; @@ -19,22 +21,25 @@ class GooglePlayStoreService : IGooglePlayStoreService IBillingClientStateListener m_BillingClientStateListener; IQuerySkuDetailsService m_QuerySkuDetailsService; Queue m_ProductsToQuery = new Queue(); - Queue>> m_OnPurchaseSucceededQueue = new Queue>>(); + ConcurrentQueue>> m_OnPurchaseSucceededQueue = new ConcurrentQueue>>(); IGooglePurchaseService m_GooglePurchaseService; IGoogleFinishTransactionService m_GoogleFinishTransactionService; IGoogleQueryPurchasesService m_GoogleQueryPurchasesService; IGooglePriceChangeService m_GooglePriceChangeService; IGoogleLastKnownProductService m_GoogleLastKnownProductService; + ITelemetryDiagnostics m_TelemetryDiagnostics; + ILogger m_Logger; - internal GooglePlayStoreService( - IGoogleBillingClient billingClient, + internal GooglePlayStoreService(IGoogleBillingClient billingClient, IQuerySkuDetailsService querySkuDetailsService, IGooglePurchaseService purchaseService, IGoogleFinishTransactionService finishTransactionService, IGoogleQueryPurchasesService queryPurchasesService, IBillingClientStateListener billingClientStateListener, IGooglePriceChangeService priceChangeService, - IGoogleLastKnownProductService lastKnownProductService) + IGoogleLastKnownProductService lastKnownProductService, + ITelemetryDiagnostics telemetryDiagnostics, + ILogger logger) { m_BillingClient = billingClient; m_QuerySkuDetailsService = querySkuDetailsService; @@ -44,6 +49,8 @@ internal GooglePlayStoreService( m_GooglePriceChangeService = priceChangeService; m_GoogleLastKnownProductService = lastKnownProductService; m_BillingClientStateListener = billingClientStateListener; + m_TelemetryDiagnostics = telemetryDiagnostics; + m_Logger = logger; InitConnectionWithGooglePlay(); } @@ -115,8 +122,7 @@ protected virtual void DequeueQueryProducts() } default: { - Debug.LogErrorFormat("GooglePlayStoreService state ({0}) unrecognized, cannot process ProductDescriptionQuery", - currentConnectionState); + m_Logger.LogIAPError($"GooglePlayStoreService state ({currentConnectionState}) unrecognized, cannot process ProductDescriptionQuery"); stop = true; break; } @@ -131,9 +137,8 @@ protected virtual void DequeueQueryProducts() protected virtual void DequeueFetchPurchases() { - while (m_OnPurchaseSucceededQueue.Count > 0) + while (m_OnPurchaseSucceededQueue.TryDequeue(out var onPurchaseSucceed)) { - var onPurchaseSucceed = m_OnPurchaseSucceededQueue.Dequeue(); FetchPurchases(onPurchaseSucceed); } } @@ -210,6 +215,24 @@ public void FinishTransaction(ProductDefinition product, string purchaseToken, A public async void FetchPurchases(Action> onQueryPurchaseSucceed) { + try + { + await TryFetchPurchases(onQueryPurchaseSucceed); + } + catch (Exception ex) + { + m_TelemetryDiagnostics.SendDiagnostic(TelemetryDiagnosticNames.FetchPurchasesError, ex); + } + } + + async Task TryFetchPurchases(Action> onQueryPurchaseSucceed) + { + if (onQueryPurchaseSucceed == null) + { + m_Logger.LogIAPWarning("FetchPurchases called with null callback onQueryPurchaseSucceed"); + return; + } + if (m_BillingClient.GetConnectionState() == GoogleBillingConnectionState.Connected) { var purchases = await m_GoogleQueryPurchasesService.QueryPurchases(); diff --git a/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs index ff24b73..b482239 100644 --- a/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs +++ b/Runtime/Stores/Android/GooglePlay/AAR/MetricizedGooglePlayStoreService.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -10,10 +12,10 @@ namespace UnityEngine.Purchasing { class MetricizedGooglePlayStoreService : GooglePlayStoreService { + ITelemetryDiagnostics m_TelemetryDiagnostics; ITelemetryMetricsService m_TelemetryMetricsService; - internal MetricizedGooglePlayStoreService( - IGoogleBillingClient billingClient, + internal MetricizedGooglePlayStoreService(IGoogleBillingClient billingClient, IQuerySkuDetailsService querySkuDetailsService, IGooglePurchaseService purchaseService, IGoogleFinishTransactionService finishTransactionService, @@ -21,10 +23,14 @@ internal MetricizedGooglePlayStoreService( IBillingClientStateListener billingClientStateListener, IGooglePriceChangeService priceChangeService, IGoogleLastKnownProductService lastKnownProductService, - ITelemetryMetricsService telemetryMetricsService) + ITelemetryDiagnostics telemetryDiagnostics, + ITelemetryMetricsService telemetryMetricsService, + ILogger logger) : base(billingClient, querySkuDetailsService, purchaseService, finishTransactionService, - queryPurchasesService, billingClientStateListener, priceChangeService, lastKnownProductService) + queryPurchasesService, billingClientStateListener, priceChangeService, lastKnownProductService, + telemetryDiagnostics, logger) { + m_TelemetryDiagnostics = telemetryDiagnostics; m_TelemetryMetricsService = telemetryMetricsService; } diff --git a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs index 486ca4a..864194d 100644 --- a/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/GooglePlayStoreExtensions.cs @@ -11,15 +11,18 @@ class GooglePlayStoreExtensions : IGooglePlayStoreExtensions, IGooglePlayStoreEx { IGooglePlayStoreService m_GooglePlayStoreService; IGooglePlayStoreFinishTransactionService m_GooglePlayStoreFinishTransactionService; + ILogger m_Logger; ITelemetryDiagnostics m_TelemetryDiagnostics; IStoreCallback m_StoreCallback; + Action m_DeferredPurchaseAction; Action m_DeferredProrationUpgradeDowngradeSubscriptionAction; - internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ITelemetryDiagnostics telemetryDiagnostics) + internal GooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ILogger logger, ITelemetryDiagnostics telemetryDiagnostics) { m_GooglePlayStoreService = googlePlayStoreService; m_GooglePlayStoreFinishTransactionService = googlePlayStoreFinishTransactionService; + m_Logger = logger; m_TelemetryDiagnostics = telemetryDiagnostics; } @@ -62,7 +65,11 @@ public virtual void UpgradeDowngradeSubscription(string oldSku, string newSku, G public virtual void RestoreTransactions(Action callback) { - m_GooglePlayStoreService.FetchPurchases(_ => { callback(true); }); + if (callback == null) + { + m_Logger.LogIAPError("RestoreTransactions called with a null callback. Please provide a callback to avoid null pointer exceptions"); + } + m_GooglePlayStoreService.FetchPurchases(_ => { callback?.Invoke(true); }); } public void ConfirmSubscriptionPriceChange(string productId, Action callback) diff --git a/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs index b23b2f9..1ee5b09 100644 --- a/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs +++ b/Runtime/Stores/Android/GooglePlay/MetricizedGooglePlayStoreExtensions.cs @@ -10,9 +10,9 @@ class MetricizedGooglePlayStoreExtensions : GooglePlayStoreExtensions internal MetricizedGooglePlayStoreExtensions(IGooglePlayStoreService googlePlayStoreService, - IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, + IGooglePlayStoreFinishTransactionService googlePlayStoreFinishTransactionService, ILogger logger, ITelemetryDiagnostics telemetryDiagnostics, ITelemetryMetricsService telemetryMetricsService) - : base(googlePlayStoreService, googlePlayStoreFinishTransactionService, telemetryDiagnostics) + : base(googlePlayStoreService, googlePlayStoreFinishTransactionService, logger, telemetryDiagnostics) { m_TelemetryMetricsService = telemetryMetricsService; } diff --git a/Runtime/Stores/StandardPurchasingModule.cs b/Runtime/Stores/StandardPurchasingModule.cs index 7e98524..89b78f6 100644 --- a/Runtime/Stores/StandardPurchasingModule.cs +++ b/Runtime/Stores/StandardPurchasingModule.cs @@ -25,7 +25,7 @@ public class StandardPurchasingModule : AbstractPurchasingModule, IAndroidStoreS /// [Obsolete("Not accurate. Use Version instead.", false)] public const string k_PackageVersion = "3.0.1"; - internal readonly string k_Version = "4.4.0"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. + internal readonly string k_Version = "4.4.1"; // NOTE: Changed using GenerateUnifiedIAP.sh before pack step. /// /// The version of com.unity.purchasing installed and the app was built using. /// @@ -274,6 +274,7 @@ private IStore InstantiateGoogleStore() var googlePlayStoreExtensions = new MetricizedGooglePlayStoreExtensions( googlePlayStoreService, googlePlayStoreFinishTransactionService, + logger, telemetryDiagnostics, telemetryMetrics); @@ -330,6 +331,7 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g var finishTransactionService = new GoogleFinishTransactionService(googleBillingClient, queryPurchasesService); var billingClientStateListener = new BillingClientStateListener(); var priceChangeService = new GooglePriceChangeService(googleBillingClient, googleQuerySkuDetailsService); + var telemetryDiagnostics = new TelemetryDiagnostics(telemetryDiagnosticsInstanceWrapper); var telemetryMetrics = new TelemetryMetricsService(telemetryMetricsInstanceWrapper); googlePurchaseUpdatedListener.SetGoogleQueryPurchaseService(queryPurchasesService); @@ -343,7 +345,9 @@ IGooglePlayStoreService BuildGooglePlayStoreServiceAar(IGooglePurchaseCallback g billingClientStateListener, priceChangeService, googleLastKnownProductService, - telemetryMetrics + telemetryDiagnostics, + telemetryMetrics, + logger ); } diff --git a/package.json b/package.json index 5da13e3..fdd9a72 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,9 @@ "supportedPlatforms": [ "Android", "iOS" - ], - "changelog": "### Added\n- GooglePlay - Google Play Billing Library version 4.0.0.\n - The Multi-quantity feature is not yet supported by the IAP package and will come in a future update. **Do not enable `Multi-quantity` in the Google Play Console.**\n - Add support for\n the [IMMEDIATE_AND_CHARGE_FULL_PRICE](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode#IMMEDIATE_AND_CHARGE_FULL_PRICE)\n proration mode. Use `GooglePlayProrationMode.ImmediateAndChargeFullPrice` for easy access.\n\n### Fixed\n- GooglePlay - Fix `IGooglePlayConfiguration.SetDeferredPurchaseListener`\n and `IGooglePlayConfiguration.SetDeferredProrationUpgradeDowngradeSubscriptionListener` callbacks sometimes not being\n called from the main thread.\n- GooglePlay - When configuring `IGooglePlayConfiguration.SetQueryProductDetailsFailedListener(Action retryCount)`, the action will be invoked with retryCount starting at 1 instead of 0.\n- GooglePlay - Added a validation when upgrading/downgrading a subscription that calls `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.ProductUnavailable` when the old transaction id is empty or null. This can occur when attempting to upgrade/downgrade a subscription that the user doesn't own." + ] }, - "version": "4.4.0", + "version": "4.4.1", "description": "IMPORTANT UPGRADE NOTES:\n\nIf updating from Unity IAP (com.unity.purchasing + the Asset Store plugin) versions 2.x to version 3.x, complete the following actions in order to resolve compilation errors:\n 1. Move IAPProductCatalog.json and BillingMode.json\n\tFROM: Assets/Plugins/UnityPurchasing/Resources/\n\tTO: Assets/Resources/.\n 2. Move AppleTangle.cs and GooglePlayTangle.cs\n\tFROM: Assets/Plugins/UnityPurchasing/generated\n\tTO: Assets/Scripts/UnityPurchasing/generated.\n 3. Remove all remaining Asset Store plugin folders and files in Assets/Plugins/UnityPurchasing from your project.\n\nPACKAGE DESCRIPTION:\n\nWith Unity IAP, setting up in-app purchases for your game across multiple app stores has never been easier.\n\nThis package provides:\n\n ▪ One common API to access all stores for free so you can fully understand and optimize your in-game economy\n ▪ Automatic coupling with Unity Analytics to enable monitoring and decision-making based on trends in your revenue and purchase data across multiple platforms\n ▪ Support for iOS, Mac, tvOS, Google Play, Windows, and Amazon app stores(*).\n ▪ Support to work with the Unity Distribution Portal to synchronize catalogs and transactions with other app stores\n ▪ Client-side receipt validation for Apple App Store and Google Play\n\nAfter installing this package, open the Services Window to enable In-App Purchasing to use these features.", "dependencies": { "com.unity.ugui": "1.0.0", @@ -34,15 +33,15 @@ "license": "Unity Companion Package License v1.0", "hideInEditor": false, "relatedPackages": { - "com.unity.purchasing.tests": "4.4.0" + "com.unity.purchasing.tests": "4.4.1" }, "upmCi": { - "footprint": "96eed2b3c93a18c9512177b87bbc7c736957dec9" + "footprint": "a69c6d1780588a2c6705495db80ad434c9e1f04e" }, "repository": { "url": "https://github.cds.internal.unity3d.com/unity/com.unity.purchasing.git", "type": "git", - "revision": "94c5a447a90fc84222a89d4cffe216b3cfba7ef6" + "revision": "9dba5d5eda45d5841b3bcb9fadeba1b2ed5ef45b" }, "samples": [ { From e0f32a6530dd67441ecbba31569be949a05ee758 Mon Sep 17 00:00:00 2001 From: Unity Technologies <@unity> Date: Fri, 23 Sep 2022 00:00:00 +0000 Subject: [PATCH 07/12] com.unity.purchasing@4.5.0 ## [4.5.0] - 2022-09-23 ### Added - Apple - Add support for [Family Sharing](https://developer.apple.com/app-store/subscriptions/#family-sharing). - API `IAppleConfiguration.SetEntitlementsRevokedListener(Action>` called when entitlement to a products are revoked. The `Action` will be called with the list of revoked products. See documentation "Store Guides" > "iOS & Mac App Stores" for a sample usage. - API - Product metadata is now available in `AppleProductMetadata` from `ProductMetadata.GetAppleProductMetadata()` via `IStoreController.products`. - API `AppleProductMetadata.isFamilyShareable` indicated if the product is family shareable. - `Apple App Store - 11 Family Sharing` sample that showcases how to use Unity IAP to manage family shared purchases. ### Fixed - GooglePlay - Processing out-of-app purchases such as Promo codes no longer requires the app to be restarted. The purchase will be processed the next time the app is foregrounded. Technical limitation: In the case of promo codes, if the app is opened while the code is redeemed, you might receive an additional call to `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.Unknown`. This can be safely ignored. - GooglePlay - Fixed a `NullReferenceException` that would rarely occur when retrieving products due to a concurrency issue introduced in Unity IAP 4.2.0 --- CHANGELOG.md | 15 + Documentation~/TableOfContents.md | 1 + Documentation~/UnityIAPAppleFamilySharing.md | 40 + .../PurchasingServiceAnalyticsSender.cs | 2 +- Editor/AppleCapabilities.cs | 2 +- Editor/ApplePriceTiers.cs | 4 +- Editor/AppleXMLProductCatalogExporter.cs | 102 +- Editor/BuildTargetGroupExtensions.cs | 34 +- Editor/GooglePlayProductCatalogExporter.cs | 94 +- Editor/IAPButtonEditor.cs | 19 +- Editor/Importer.cs | 4 +- Editor/MenuItems/IapButtonMenu.cs | 4 +- Editor/MenuItems/IapListenerMenu.cs | 2 +- .../Service/ObfuscationGenerator.cs | 25 +- .../Service/ObfuscationMigration.cs | 4 +- .../Obfuscation/Service/TangleObfuscator.cs | 18 +- Editor/Obfuscation/UI/ObfuscatorWindow.cs | 9 +- Editor/ProductCatalogEditor.cs | 229 +++-- Editor/RichEditorWindow.cs | 10 +- .../Presenter/BasePurchasingState.cs | 8 +- .../PurchasingServiceEnabler.cs | 9 +- .../PurchasingSettingsProvider.cs | 5 +- .../Service/GoogleConfigService.cs | 6 +- .../GoogleConfigurationWebRequests.cs | 32 +- .../Service/Networking/NetworkingUtils.cs | 3 +- .../SimpleStateMachine.cs | 8 +- .../Views/AppleConfigurationSettingsBlock.cs | 10 +- .../GooglePlayConfigurationSettingsBlock.cs | 30 +- .../UI/Views/IMGUIContainerPopupAdapter.cs | 5 +- .../Obfuscator/AbstractObfuscatorSection.cs | 2 +- .../Views/Obfuscator/BaseObfuscatorSection.cs | 2 +- .../Obfuscator/GoogleObfuscatorSection.cs | 2 +- Editor/UdpInstaller.cs | 2 +- Editor/UdpSynchronizationApi.cs | 39 +- Editor/UnityPurchasingEditor.cs | 36 +- Editor/WinRTPatcher.cs | 10 +- Plugins/UnityPurchasing/iOS/UnityPurchasing.m | 27 +- .../Contents/MacOS/unitypurchasing | Bin 296720 -> 297120 bytes Runtime/AppleMacosStub/OSXStoreBindings.cs | 26 +- Runtime/AppleStub/iOSStoreBindings.cs | 26 +- Runtime/Codeless/CodelessIAPStoreListener.cs | 33 +- Runtime/Codeless/IAPButton.cs | 4 +- Runtime/Codeless/IAPConfigurationHelper.cs | 3 +- Runtime/Codeless/IAPListener.cs | 9 +- Runtime/Common/MiniJSON.cs | 96 +- Runtime/Common/VersionCheck.cs | 18 +- .../Purchasing/Analytics/AnalyticsAdapter.cs | 2 +- .../Purchasing/Analytics/AnalyticsClient.cs | 4 +- .../Legacy/LegacyAnalyticsAdapter.cs | 2 +- Runtime/Purchasing/ConfigurationBuilder.cs | 35 +- .../UnityServicesInitializationChecker.cs | 3 +- .../Extension/AbstractPurchasingModule.cs | 2 +- Runtime/Purchasing/Extension/AbstractStore.cs | 2 +- Runtime/Purchasing/Extension/IStore.cs | 2 +- .../Purchasing/Extension/IStoreCallback.cs | 2 +- Runtime/Purchasing/PayoutDefinition.cs | 52 +- Runtime/Purchasing/Product.cs | 13 +- Runtime/Purchasing/ProductCollection.cs | 23 +- Runtime/Purchasing/ProductDefinition.cs | 28 +- Runtime/Purchasing/ProductMetadata.cs | 13 + Runtime/Purchasing/PurchasingFactory.cs | 15 +- Runtime/Purchasing/PurchasingManager.cs | 57 +- Runtime/Purchasing/SimpleCatalogProvider.cs | 7 +- Runtime/Purchasing/StoreListenerProxy.cs | 6 +- .../Diagnostics/TelemetryDiagnostics.cs | 2 +- .../TelemetryDiagnosticsInstanceWrapper.cs | 2 +- .../Metrics/TelemetryMetricDefinition.cs | 5 +- .../Telemetry/Metrics/TelemetryMetricEvent.cs | 6 +- .../TelemetryMetricsInstanceWrapper.cs | 2 +- .../Metrics/TelemetryMetricsService.cs | 2 +- .../Purchasing/Telemetry/TelemetryQueue.cs | 2 +- Runtime/Purchasing/TransactionLog.cs | 11 +- .../Purchasing/UnifiedReceiptExtensions.cs | 24 +- Runtime/Purchasing/UnityPurchasing.cs | 2 +- .../Utilites/UnifiedReceiptFormatter.cs | 2 +- Runtime/Security/CrossPlatformValidator.cs | 64 +- Runtime/SecurityCore/IAPSecurityException.cs | 2 +- .../SecurityStub/CrossPlatformValidator.cs | 2 +- .../AmazonAppStoreStoreExtensions.cs | 14 +- .../Android/AmazonAppStore/FakeAmazon.cs | 5 +- Runtime/Stores/Android/AndroidJavaStore.cs | 4 +- .../Android/Common/AAR/ListExtension.cs | 4 +- .../AAR/GoogleCachedQuerySkuDetailsService.cs | 2 +- .../AAR/GoogleFinishTransactionService.cs | 4 +- .../GooglePlay/AAR/GooglePlayStoreService.cs | 71 +- .../AAR/GooglePriceChangeService.cs | 4 +- .../GooglePlay/AAR/GooglePurchaseService.cs | 6 +- .../AAR/GoogleQueryPurchasesService.cs | 4 +- .../GoogleAcknowledgePurchaseListener.cs | 3 +- .../GoogleConsumeResponseListener.cs | 3 +- .../GooglePriceChangeConfirmationListener.cs | 3 +- .../GooglePurchaseUpdatedListener.cs | 11 +- .../GooglePurchasesResponseListener.cs | 3 +- .../Listeners/SkuDetailsResponseListener.cs | 5 +- .../AAR/MetricizedGooglePlayStoreService.cs | 7 +- .../AAR/Models/GoogleBillingClient.cs | 10 +- .../GooglePlay/AAR/QuerySkuDetailsService.cs | 10 +- .../GooglePlay/AAR/SkuDetailsQueryResponse.cs | 2 +- .../AAR/SkuDetailsResponseConsolidator.cs | 5 +- .../AAR/Utils/GooglePurchaseBuilder.cs | 4 +- .../AAR/Utils/SkuDetailsConverter.cs | 28 +- .../GetGoogleProductMetadataExtension.cs | 15 +- .../GooglePlay/GooglePlayConfiguration.cs | 2 +- .../GooglePlay/GooglePlayPurchaseCallback.cs | 2 +- .../Android/GooglePlay/GooglePlayStore.cs | 16 +- .../GooglePlay/GooglePlayStoreExtensions.cs | 60 +- .../MetricizedGooglePlayStoreExtensions.cs | 2 +- .../Services/GoogleFetchPurchases.cs | 2 +- ...GooglePlayStoreFinishTransactionService.cs | 4 +- .../GooglePlayStorePurchaseService.cs | 2 +- .../GooglePlayStoreRetrieveProductsService.cs | 6 +- Runtime/Stores/Android/JSONSerializer.cs | 60 +- Runtime/Stores/Android/JavaBridge.cs | 2 +- .../Stores/Android/ScriptingStoreCallback.cs | 4 +- .../Stores/Android/ScriptingUnityCallback.cs | 4 +- .../Stores/Android/UDP/FakeUDPExtension.cs | 4 +- Runtime/Stores/Android/UDP/UDPBindings.cs | 32 +- Runtime/Stores/Android/UDP/UDPImpl.cs | 16 +- .../Stores/Android/UDP/UDPReflectionUtil.cs | 6 +- ...ppleJsonProductDescriptionsDeserializer.cs | 16 + ...sonProductDescriptionsDeserializer.cs.meta | 3 + .../AppleAppStore/AppleProductMetadata.cs | 29 + .../AppleProductMetadata.cs.meta | 3 + .../Stores/AppleAppStore/AppleStoreImpl.cs | 353 ++++--- .../AppleAppStore/FakeAppleConfiguration.cs | 23 +- .../AppleAppStore/FakeAppleExtensions.cs | 5 + .../GetAppleProductMetadataExtension.cs | 20 + .../GetAppleProductMetadataExtension.cs.meta | 3 + .../AppleAppStore/IAppleConfiguration.cs | 11 +- .../AppleAppStore/MetricizedAppleStoreImpl.cs | 2 +- Runtime/Stores/BaseStore/JSONStore.cs | 33 +- .../Stores/BaseStore/MetricizedJsonStore.cs | 2 +- .../Stores/BaseStore/NativeStoreProvider.cs | 30 +- Runtime/Stores/FakeStore/FakeStore.cs | 77 +- Runtime/Stores/FakeStore/LifecycleNotifier.cs | 5 +- Runtime/Stores/FakeStore/UIFakeStore.cs | 53 +- .../Stores/FakeStore/UIFakeStoreDropdown.cs | 4 +- Runtime/Stores/FakeStore/UIFakeStoreWindow.cs | 19 +- Runtime/Stores/Networking/QueryHelper.cs | 8 +- Runtime/Stores/ProductCatalog.cs | 189 ++-- Runtime/Stores/StandardPurchasingModule.cs | 73 +- Runtime/Stores/StoreConfiguration.cs | 9 +- Runtime/Stores/SubscriptionManager.cs | 424 ++++---- Runtime/Stores/Util/ExponentialRetryPolicy.cs | 6 +- Runtime/Stores/Util/FileReference.cs | 4 +- .../JsonProductDescriptionsDeserializer.cs | 55 ++ ...sonProductDescriptionsDeserializer.cs.meta | 11 + .../Util/ProductDefinitionExtensions.cs | 20 +- Runtime/Stores/Util/UnityUtil.cs | 112 +-- Runtime/Stores/WindowsStore/WinRTStore.cs | 18 +- Runtime/WinRTCore/WinProductDescription.cs | 6 +- .../Apple App Store - 11 FamilySharing.meta | 8 + .../.sample.json | 5 + .../FamilySharing.cs | 143 +++ .../FamilySharing.cs.meta | 3 + .../FamilySharing.unity | 903 ++++++++++++++++++ .../FamilySharing.unity.meta | 7 + .../README.md | 32 + .../README.md.meta | 3 + .../UserWarningAppleAppStore.cs | 26 + .../UserWarningAppleAppStore.cs.meta | 3 + package.json | 13 +- 162 files changed, 2944 insertions(+), 1744 deletions(-) create mode 100644 Documentation~/UnityIAPAppleFamilySharing.md create mode 100644 Runtime/Stores/AppleAppStore/AppleJsonProductDescriptionsDeserializer.cs create mode 100644 Runtime/Stores/AppleAppStore/AppleJsonProductDescriptionsDeserializer.cs.meta create mode 100644 Runtime/Stores/AppleAppStore/AppleProductMetadata.cs create mode 100644 Runtime/Stores/AppleAppStore/AppleProductMetadata.cs.meta create mode 100644 Runtime/Stores/AppleAppStore/GetAppleProductMetadataExtension.cs create mode 100644 Runtime/Stores/AppleAppStore/GetAppleProductMetadataExtension.cs.meta create mode 100644 Runtime/Stores/Util/JsonProductDescriptionsDeserializer.cs create mode 100644 Runtime/Stores/Util/JsonProductDescriptionsDeserializer.cs.meta create mode 100644 Samples~/Apple App Store - 11 FamilySharing.meta create mode 100644 Samples~/Apple App Store - 11 FamilySharing/.sample.json create mode 100644 Samples~/Apple App Store - 11 FamilySharing/FamilySharing.cs create mode 100644 Samples~/Apple App Store - 11 FamilySharing/FamilySharing.cs.meta create mode 100644 Samples~/Apple App Store - 11 FamilySharing/FamilySharing.unity create mode 100644 Samples~/Apple App Store - 11 FamilySharing/FamilySharing.unity.meta create mode 100644 Samples~/Apple App Store - 11 FamilySharing/README.md create mode 100644 Samples~/Apple App Store - 11 FamilySharing/README.md.meta create mode 100644 Samples~/Apple App Store - 11 FamilySharing/UserWarningAppleAppStore.cs create mode 100644 Samples~/Apple App Store - 11 FamilySharing/UserWarningAppleAppStore.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e544c..a25d2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [4.5.0] - 2022-09-23 +### Added +- Apple - Add support for [Family Sharing](https://developer.apple.com/app-store/subscriptions/#family-sharing). + - API `IAppleConfiguration.SetEntitlementsRevokedListener(Action>` called when entitlement to a products are revoked. The `Action` will be called with the list of revoked products. See documentation "Store Guides" > "iOS & Mac App Stores" for a sample usage. + - API - Product metadata is now available in `AppleProductMetadata` from `ProductMetadata.GetAppleProductMetadata()` via `IStoreController.products`. + - API `AppleProductMetadata.isFamilyShareable` indicated if the product is family shareable. + - `Apple App Store - 11 Family Sharing` sample that showcases how to use Unity IAP to manage family shared purchases. + +### Fixed +- GooglePlay - Processing out-of-app purchases such as Promo codes no longer requires the app to be restarted. The + purchase will be processed the next time the app is foregrounded. Technical limitation: In the case of promo codes, if + the app is opened while the code is redeemed, you might receive an additional call + to `IStoreListener.OnPurchaseFailed` with `PurchaseFailureReason.Unknown`. This can be safely ignored. +- GooglePlay - Fixed a `NullReferenceException` that would rarely occur when retrieving products due to a concurrency issue introduced in Unity IAP 4.2.0 + ## [4.4.1] - 2022-08-11 ### Fixed - GooglePlay - Fixed NullReferenceException and ArgumentException that would rarely occur due to a concurrency issue introduced in Unity IAP 4.2.0 diff --git a/Documentation~/TableOfContents.md b/Documentation~/TableOfContents.md index ac9d08d..1f9c79d 100644 --- a/Documentation~/TableOfContents.md +++ b/Documentation~/TableOfContents.md @@ -47,6 +47,7 @@ * [Extensions and Configuration](UnityIAPiOSMAS.md) * [Purchase Receipt](AppleReceipt.md) * [Testing](AppleTesting.md) + * [Family Sharing](UnityIAPAppleFamilySharing.md) * Microsoft Store (UWP) * [How to Set Up](UnityIAPWindowsConfiguration.md) * [Purchase Receipt](MicrosoftReceipt.md) diff --git a/Documentation~/UnityIAPAppleFamilySharing.md b/Documentation~/UnityIAPAppleFamilySharing.md new file mode 100644 index 0000000..e8211a1 --- /dev/null +++ b/Documentation~/UnityIAPAppleFamilySharing.md @@ -0,0 +1,40 @@ +# [Apple Family Sharing](https://developer.apple.com/app-store/subscriptions/#family-sharing) + +## Introduction + +Apple allows auto-renewable subscriptions and non-consumable in-app purchases to be shared within a family. +In order to use this feature, Family Sharing must be enabled on a per purchasable basis. See [Turn on Family Sharing for in-app purchases](https://help.apple.com/app-store-connect/#/dev45b03fab9). + +### Is Family Shareable + +The family shareable status of a product is available through the `isFamilyShareable` field found in the Apple product metadata. +The metadata can be obtained from `ProductMetadata.GetAppleProductMetadata()` via `IStoreController.products`. +```` + bool IsProductFamilyShareable(Product product) + { + var appleProductMetadata = product.metadata.GetAppleProductMetadata(); + return appleProductMetadata?.isFamilyShareable ?? false; + } +```` + +### Revoke Entitlement + +In order to be handle revoked entitlements, you can specify a listener through the `IAppleConfiguration.SetEntitlementsRevokedListener(Action>`. +This will be called each time products have been revoked with the list of revoked products. +```` + void InitializePurchasing() + { + var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance()); + builder.Configure().SetEntitlementsRevokedListener(EntitlementsRevokeListener); + + UnityPurchasing.Initialize(this, builder); + } + + void EntitlementsRevokeListener(List revokedProducts) + { + foreach (var revokedProduct in revokedProducts) + { + Debug.Log($"Revoked product: {revokedProduct.definition.id}"); + } + } +```` diff --git a/Editor/Analytics/PurchasingServiceAnalyticsSender.cs b/Editor/Analytics/PurchasingServiceAnalyticsSender.cs index dad0bd5..fd7d409 100644 --- a/Editor/Analytics/PurchasingServiceAnalyticsSender.cs +++ b/Editor/Analytics/PurchasingServiceAnalyticsSender.cs @@ -5,7 +5,7 @@ namespace UnityEditor.Purchasing [InitializeOnLoad] internal static class PurchasingServiceAnalyticsSender { - static IAnalyticsPackageKeyHolder m_Holder; + static readonly IAnalyticsPackageKeyHolder m_Holder; static PurchasingServiceAnalyticsSender() { diff --git a/Editor/AppleCapabilities.cs b/Editor/AppleCapabilities.cs index 311fbc2..208f213 100644 --- a/Editor/AppleCapabilities.cs +++ b/Editor/AppleCapabilities.cs @@ -48,7 +48,7 @@ static void AddInAppPurchasingCapability(string projPath, PBXProject proj) static void AddStoreKitFramework(PBXProject proj, string projPath) { - foreach (var targetGuid in new [] {proj.GetUnityMainTargetGuid(), proj.GetUnityFrameworkTargetGuid()}) + foreach (var targetGuid in new[] { proj.GetUnityMainTargetGuid(), proj.GetUnityFrameworkTargetGuid() }) { proj.AddFrameworkToProject(targetGuid, k_StorekitFramework, false); System.IO.File.WriteAllText(projPath, proj.WriteToString()); diff --git a/Editor/ApplePriceTiers.cs b/Editor/ApplePriceTiers.cs index f50a10e..3f6dbd8 100644 --- a/Editor/ApplePriceTiers.cs +++ b/Editor/ApplePriceTiers.cs @@ -29,7 +29,9 @@ internal static int[] RoundedDollars internal static double ActualDollarsForAppleTier(int tier) { if (RoundedDollars[tier] == 0) + { return 0; + } return RoundedDollars[tier] - 0.01; } @@ -90,7 +92,7 @@ private static void GenerateAppleTierData() private static string CreateApplePriceTierString(int tier, int roundedDollars) { - return string.Format("Tier {0} - USD {1:0.00}", tier, (float)roundedDollars - 0.01f); + return string.Format("Tier {0} - USD {1:0.00}", tier, roundedDollars - 0.01f); } } } diff --git a/Editor/AppleXMLProductCatalogExporter.cs b/Editor/AppleXMLProductCatalogExporter.cs index 2500a37..20b2bce 100644 --- a/Editor/AppleXMLProductCatalogExporter.cs +++ b/Editor/AppleXMLProductCatalogExporter.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; -using System.IO; -using System.Text; using System.Collections.ObjectModel; +using System.IO; using System.Security.Cryptography; +using System.Text; using System.Xml.Linq; using UnityEngine.Purchasing; using ExporterValidationResults = UnityEditor.Purchasing.ProductCatalogEditor.ExporterValidationResults; @@ -20,61 +20,19 @@ internal class AppleXMLProductCatalogExporter : ProductCatalogEditor.IProductCat internal List kFilesToCopy = new List(); private const string kNewLine = "\n"; - public string DisplayName - { - get - { - return "Apple XML Delivery"; - } - } + public string DisplayName => "Apple XML Delivery"; - public string DefaultFileName - { - get - { - return "metadata"; - } - } + public string DefaultFileName => "metadata"; - public string FileExtension - { - get - { - return "xml"; - } - } + public string FileExtension => "xml"; - public string StoreName - { - get - { - return AppleAppStore.Name; - } - } + public string StoreName => AppleAppStore.Name; - public string MandatoryExportFolder - { - get - { - return kMandatoryExportFolder; - } - } + public string MandatoryExportFolder => kMandatoryExportFolder; - public List FilesToCopy - { - get - { - return kFilesToCopy; - } - } + public List FilesToCopy => kFilesToCopy; - public bool SaveCompletePackage - { - get - { - return true; - } - } + public bool SaveCompletePackage => true; public string Export(ProductCatalog catalog) { @@ -83,45 +41,45 @@ public string Export(ProductCatalog catalog) // throughout this method, so this is converted to a list and then // wrapped in a ReadOnlyCollection to prevent mutation. var localesToExport = new ReadOnlyCollection(new List(GetLocalesToExport(catalog))); - XDeclaration declaration = new XDeclaration("1.0", "utf-8", "yes"); - XDocument document = new XDocument(); + var declaration = new XDeclaration("1.0", "utf-8", "yes"); + var document = new XDocument(); XNamespace ns = "http://apple.com/itunes/importer"; - XElement package = new XElement(ns + "package", + var package = new XElement(ns + "package", new XAttribute("version", "software5.7")); document.Add(package); package.Add(new XElement(ns + "provider", catalog.appleTeamID)); package.Add(new XElement(ns + "team_id", catalog.appleTeamID)); - XElement software = new XElement(ns + "software"); + var software = new XElement(ns + "software"); package.Add(software); software.Add(new XElement(ns + "vendor_id", catalog.appleSKU)); - XElement softwareMetadata = new XElement(ns + "software_metadata"); + var softwareMetadata = new XElement(ns + "software_metadata"); software.Add(softwareMetadata); - XElement inAppPurchases = new XElement(ns + "in_app_purchases"); + var inAppPurchases = new XElement(ns + "in_app_purchases"); softwareMetadata.Add(inAppPurchases); foreach (var item in catalog.allProducts) { - XElement inAppPurchase = new XElement(ns + "in_app_purchase", + var inAppPurchase = new XElement(ns + "in_app_purchase", new XElement(ns + "product_id", item.GetStoreID(AppleAppStore.Name) ?? item.id), new XElement(ns + "reference_name", item.id), new XElement(ns + "type", ProductTypeString(item))); - XElement products = new XElement(ns + "products"); + var products = new XElement(ns + "products"); inAppPurchase.Add(products); - XElement product = new XElement(ns + "product", + var product = new XElement(ns + "product", new XElement(ns + "cleared_for_sale", true), new XElement(ns + "wholesale_price_tier", item.applePriceTier)); products.Add(product); - XElement locales = new XElement(ns + "locales"); + var locales = new XElement(ns + "locales"); inAppPurchase.Add(locales); // Variable number of localizations, not every product will specify a localization for every language // so some of the these descriptions may be missing, in which case we just skip it. foreach (var loc in localesToExport) { - LocalizedProductDescription desc = item.defaultDescription.googleLocale == loc ? item.defaultDescription : item.GetDescription(loc); + var desc = item.defaultDescription.googleLocale == loc ? item.defaultDescription : item.GetDescription(loc); if (desc != null) { - XElement locale = new XElement(ns + "locale", + var locale = new XElement(ns + "locale", new XAttribute("name", LocaleToAppleString(loc)), new XElement(ns + "title", desc.Title), new XElement(ns + "description", desc.Description)); @@ -129,10 +87,10 @@ public string Export(ProductCatalog catalog) } } - XElement reviewScreenshot = new XElement(ns + "review_screenshot"); + var reviewScreenshot = new XElement(ns + "review_screenshot"); inAppPurchase.Add(reviewScreenshot); reviewScreenshot.Add(new XElement(ns + "file_name", Path.GetFileName(item.screenshotPath))); - FileInfo fileInfo = new FileInfo(item.screenshotPath); + var fileInfo = new FileInfo(item.screenshotPath); if (fileInfo.Exists) { reviewScreenshot.Add(new XElement(ns + "size", fileInfo.Length)); @@ -255,12 +213,16 @@ private HashSet GetLocalesToExport(ProductCatalog catalog) foreach (var item in catalog.allProducts) { if (item.defaultDescription.googleLocale.SupportedOnApple()) + { locs.Add(item.defaultDescription.googleLocale); + } foreach (var desc in item.translatedDescriptions) { if (desc.googleLocale.SupportedOnApple()) + { locs.Add(desc.googleLocale); + } } } @@ -321,18 +283,18 @@ public ProductCatalog NormalizeToType(ProductCatalog catalog) public static string GetMD5Hash(FileInfo fileInfo) { - MD5 md5 = MD5.Create(); - FileStream fileStream = fileInfo.OpenRead(); + var md5 = MD5.Create(); + var fileStream = fileInfo.OpenRead(); // Convert the input string to a byte array and compute the hash. - byte[] data = md5.ComputeHash(fileStream); + var data = md5.ComputeHash(fileStream); // Create a new StringBuilder to collect the bytes // and create a string. - StringBuilder stringBuilder = new StringBuilder(); + var stringBuilder = new StringBuilder(); // Loop through each byte of the hashed data // and format each one as a hexadecimal string. - for (int i = 0; i < data.Length; i++) + for (var i = 0; i < data.Length; i++) { stringBuilder.Append(data[i].ToString("x2")); } diff --git a/Editor/BuildTargetGroupExtensions.cs b/Editor/BuildTargetGroupExtensions.cs index 482c344..80739c8 100644 --- a/Editor/BuildTargetGroupExtensions.cs +++ b/Editor/BuildTargetGroupExtensions.cs @@ -24,10 +24,10 @@ internal static ReadOnlyCollection ToAppStores(this BuildTargetGroup v switch (value) { case BuildTargetGroup.Android: - { - storesArray = ToAndroidAppStores(value); - break; - } + { + storesArray = ToAndroidAppStores(value); + break; + } case BuildTargetGroup.iOS: case BuildTargetGroup.tvOS: @@ -77,22 +77,22 @@ internal static string ToPlatformDisplayName(this BuildTargetGroup value) switch (value) { case BuildTargetGroup.iOS: - { - // TRICKY: Prefer an "iOS" string on BuildTarget, to avoid the unwanted "BuildTargetGroup.iPhone" - return BuildTarget.iOS.ToString(); - } + { + // TRICKY: Prefer an "iOS" string on BuildTarget, to avoid the unwanted "BuildTargetGroup.iPhone" + return BuildTarget.iOS.ToString(); + } case BuildTargetGroup.Standalone: + { + switch (EditorUserBuildSettings.activeBuildTarget) { - switch (EditorUserBuildSettings.activeBuildTarget) - { - case BuildTarget.StandaloneOSX: - return "macOS"; - case BuildTarget.StandaloneWindows: - return "Windows"; - default: - return BuildTargetGroup.Standalone.ToString(); - } + case BuildTarget.StandaloneOSX: + return "macOS"; + case BuildTarget.StandaloneWindows: + return "Windows"; + default: + return BuildTargetGroup.Standalone.ToString(); } + } default: return value.ToString(); } diff --git a/Editor/GooglePlayProductCatalogExporter.cs b/Editor/GooglePlayProductCatalogExporter.cs index ee5ad99..675853a 100644 --- a/Editor/GooglePlayProductCatalogExporter.cs +++ b/Editor/GooglePlayProductCatalogExporter.cs @@ -1,6 +1,6 @@ -using System.Text; -using System.Collections.Generic; using System; +using System.Collections.Generic; +using System.Text; using UnityEngine.Purchasing; using ExporterValidationResults = UnityEditor.Purchasing.ProductCatalogEditor.ExporterValidationResults; @@ -11,61 +11,19 @@ namespace UnityEditor.Purchasing /// internal class GooglePlayProductCatalogExporter : ProductCatalogEditor.IProductCatalogExporter { - public string DisplayName - { - get - { - return "Google Play CSV"; - } - } + public string DisplayName => "Google Play CSV"; - public string DefaultFileName - { - get - { - return "GooglePlayProductCatalog"; - } - } + public string DefaultFileName => "GooglePlayProductCatalog"; - public string FileExtension - { - get - { - return "csv"; - } - } + public string FileExtension => "csv"; - public string StoreName - { - get - { - return GooglePlay.Name; - } - } + public string StoreName => GooglePlay.Name; - public string MandatoryExportFolder - { - get - { - return null; - } - } + public string MandatoryExportFolder => null; - public List FilesToCopy - { - get - { - return null; - } - } + public List FilesToCopy => null; - public bool SaveCompletePackage - { - get - { - return false; - } - } + public bool SaveCompletePackage => false; public string Export(ProductCatalog catalog) { @@ -86,14 +44,9 @@ public string Export(ProductCatalog catalog) foreach (var product in catalog.allProducts) { - if (string.IsNullOrEmpty(product.GetStoreID(GooglePlay.Name))) - { - values[0] = CSVEscape(product.id); - } - else - { - values[0] = CSVEscape(product.GetStoreID(GooglePlay.Name)); - } + values[0] = string.IsNullOrEmpty(product.GetStoreID(GooglePlay.Name)) + ? CSVEscape(product.id) + : CSVEscape(product.GetStoreID(GooglePlay.Name)); values[1] = "published"; values[2] = ProductTypeString(product.type); @@ -190,11 +143,11 @@ public ExporterValidationResults Validate(ProductCatalogItem item) // A product ID must start with a lowercase letter or a number and must be composed // of only lowercase letters (a-z), numbers (0-9), underscores (_), and periods (.) - string actualID = item.GetStoreID(GooglePlay.Name) ?? item.id; - string field = (actualID == item.GetStoreID(GooglePlay.Name)) ? "storeID." + GooglePlay.Name : "id"; + var actualID = item.GetStoreID(GooglePlay.Name) ?? item.id; + var field = (actualID == item.GetStoreID(GooglePlay.Name)) ? "storeID." + GooglePlay.Name : "id"; if (Char.IsNumber(actualID[0]) || (Char.IsLower(actualID[0]) && Char.IsLetter(actualID[0]))) { - foreach (char c in actualID) + foreach (var c in actualID) { if (c != '_' && c != '.' && !Char.IsNumber(c) && !(Char.IsLetter(c) && Char.IsLower(c))) { @@ -267,12 +220,14 @@ private void ValidateDescription(LocalizedProductDescription desc, ref ExporterV private const string kBackslash = "\\"; private const string kQuote = "\""; private const string kEscapedQuote = "\"\""; - private static char[] kCSVCharactersToQuote = { ',', '"', '\n' }; + private static readonly char[] kCSVCharactersToQuote = { ',', '"', '\n' }; private static string CSVEscape(string s) { if (s == null) + { return s; + } if (s.Contains(kQuote)) { @@ -290,7 +245,9 @@ private static string CSVEscape(string s) private static string SSVEscape(string s) { if (s == null) + { return s; + } s.Replace(kBackslash, kBackslash + kBackslash); s.Replace(kSemicolon, kBackslash + kSemicolon); @@ -304,11 +261,12 @@ private static string ProductTypeString(ProductType type) private static string PackTitlesAndDescriptions(ProductCatalogItem product) { - var values = new List(); - - values.Add(product.defaultDescription.googleLocale.ToString()); - values.Add(SSVEscape(product.defaultDescription.Title)); - values.Add(SSVEscape(product.defaultDescription.Description)); + var values = new List + { + product.defaultDescription.googleLocale.ToString(), + SSVEscape(product.defaultDescription.Title), + SSVEscape(product.defaultDescription.Description) + }; foreach (var desc in product.translatedDescriptions) { diff --git a/Editor/IAPButtonEditor.cs b/Editor/IAPButtonEditor.cs index 5c29801..ef3bde8 100644 --- a/Editor/IAPButtonEditor.cs +++ b/Editor/IAPButtonEditor.cs @@ -1,6 +1,6 @@ +using System.Collections.Generic; using UnityEngine; using UnityEngine.Purchasing; -using System.Collections.Generic; using static UnityEditor.Purchasing.UnityPurchasingEditor; namespace UnityEditor.Purchasing @@ -16,7 +16,7 @@ public class IAPButtonEditor : Editor private static readonly string[] restoreButtonExcludedFields = new string[] { "m_Script", "consumePurchase", "onPurchaseComplete", "onPurchaseFailed", "titleText", "descriptionText", "priceText" }; private const string kNoProduct = ""; - private List m_ValidIDs = new List(); + private readonly List m_ValidIDs = new List(); private SerializedProperty m_ProductIDProperty; /// @@ -32,7 +32,7 @@ public void OnEnable() /// public override void OnInspectorGUI() { - IAPButton button = (IAPButton)target; + var button = (IAPButton)target; serializedObject.Update(); @@ -49,16 +49,9 @@ public override void OnInspectorGUI() m_ValidIDs.Add(product.id); } - int currentIndex = string.IsNullOrEmpty(button.productId) ? 0 : m_ValidIDs.IndexOf(button.productId); - int newIndex = EditorGUILayout.Popup(currentIndex, m_ValidIDs.ToArray()); - if (newIndex > 0 && newIndex < m_ValidIDs.Count) - { - m_ProductIDProperty.stringValue = m_ValidIDs[newIndex]; - } - else - { - m_ProductIDProperty.stringValue = string.Empty; - } + var currentIndex = string.IsNullOrEmpty(button.productId) ? 0 : m_ValidIDs.IndexOf(button.productId); + var newIndex = EditorGUILayout.Popup(currentIndex, m_ValidIDs.ToArray()); + m_ProductIDProperty.stringValue = newIndex > 0 && newIndex < m_ValidIDs.Count ? m_ValidIDs[newIndex] : string.Empty; if (GUILayout.Button("IAP Catalog...")) { diff --git a/Editor/Importer.cs b/Editor/Importer.cs index 123af18..f48ab90 100644 --- a/Editor/Importer.cs +++ b/Editor/Importer.cs @@ -1,6 +1,6 @@ -using UnityEditor.Purchasing; -using UnityEditor.Build; using System; +using UnityEditor.Build; +using UnityEditor.Purchasing; namespace UnityEditor { diff --git a/Editor/MenuItems/IapButtonMenu.cs b/Editor/MenuItems/IapButtonMenu.cs index 092d0de..4653a8f 100644 --- a/Editor/MenuItems/IapButtonMenu.cs +++ b/Editor/MenuItems/IapButtonMenu.cs @@ -33,11 +33,11 @@ public static void CreateUnityIAPButton() static void CreateUnityIAPButtonInternal() { - GameObject buttonObject = CreateButtonObject(); + var buttonObject = CreateButtonObject(); if (buttonObject) { - IAPButton iapButton = buttonObject.AddComponent(); + var iapButton = buttonObject.AddComponent(); if (iapButton != null) { diff --git a/Editor/MenuItems/IapListenerMenu.cs b/Editor/MenuItems/IapListenerMenu.cs index 94f1b83..2d17f34 100644 --- a/Editor/MenuItems/IapListenerMenu.cs +++ b/Editor/MenuItems/IapListenerMenu.cs @@ -34,7 +34,7 @@ public static void CreateUnityIAPListener() static void CreateUnityIAPListenerInternal() { - GameObject listenerObject = CreateListenerObject(); + var listenerObject = CreateListenerObject(); if (listenerObject) { diff --git a/Editor/Obfuscation/Service/ObfuscationGenerator.cs b/Editor/Obfuscation/Service/ObfuscationGenerator.cs index 25e6fa0..2027015 100644 --- a/Editor/Obfuscation/Service/ObfuscationGenerator.cs +++ b/Editor/Obfuscation/Service/ObfuscationGenerator.cs @@ -85,12 +85,12 @@ static string WriteObfuscatedAppleClassAsAsset() static string WriteObfuscatedAppleClassAsAsset(string certPath, string classIncompleteErr, string classPrefix) { string appleError = null; - int key = 0; - int[] order = new int[0]; - byte[] tangled = new byte[0]; + var key = 0; + var order = new int[0]; + var tangled = new byte[0]; try { - byte[] bytes = File.ReadAllBytes(certPath); + var bytes = File.ReadAllBytes(certPath); order = new int[bytes.Length / 20 + 1]; // TODO: Integrate with upgraded Tangle! @@ -111,9 +111,9 @@ static string WriteObfuscatedAppleClassAsAsset(string certPath, string classInco static string WriteObfuscatedGooglePlayClassAsAsset(string googlePlayPublicKey) { string googleError = null; - int key = 0; - int[] order = new int[0]; - byte[] tangled = new byte[0]; + var key = 0; + var order = new int[0]; + var tangled = new byte[0]; try { var bytes = Convert.FromBase64String(googlePlayPublicKey); @@ -155,7 +155,7 @@ static bool ObfuscatedClassExists(string classnamePrefix) static void WriteObfuscatedClassAsAsset(string classnamePrefix, int key, int[] order, byte[] data, bool populated) { - Dictionary substitutionDictionary = new Dictionary() + var substitutionDictionary = new Dictionary() { {"{NAME}", classnamePrefix.ToString()}, {"{KEY}", key.ToString()}, @@ -164,12 +164,11 @@ static void WriteObfuscatedClassAsAsset(string classnamePrefix, int key, int[] o {"{POPULATED}", populated.ToString().ToLowerInvariant()} // Defaults to XML-friendly values }; - string templateRelativePath = null; - string templateText = LoadTemplateText(out templateRelativePath); + var templateText = LoadTemplateText(out var templateRelativePath); if (templateText != null) { - string outfileText = templateText; + var outfileText = templateText; // Apply the parameters to the template foreach (var pair in substitutionDictionary) @@ -188,7 +187,7 @@ static void WriteObfuscatedClassAsAsset(string classnamePrefix, int key, int[] o /// Relative Assets/ path to template file. static string LoadTemplateText(out string templateRelativePath) { - string[] assetGUIDs = + var assetGUIDs = AssetDatabase.FindAssets(m_GeneratedCredentialsTemplateFilenameNoExtension); string templateGUID = null; templateRelativePath = null; @@ -209,7 +208,7 @@ static string LoadTemplateText(out string templateRelativePath) { templateRelativePath = AssetDatabase.GUIDToAssetPath(templateGUID); - string templateAbsolutePath = + var templateAbsolutePath = Path.GetDirectoryName(Application.dataPath) + Path.DirectorySeparatorChar + templateRelativePath; diff --git a/Editor/Obfuscation/Service/ObfuscationMigration.cs b/Editor/Obfuscation/Service/ObfuscationMigration.cs index d910711..5a13c54 100644 --- a/Editor/Obfuscation/Service/ObfuscationMigration.cs +++ b/Editor/Obfuscation/Service/ObfuscationMigration.cs @@ -56,12 +56,12 @@ static void MoveObfuscatorFile(string file) internal static bool CheckPreviousObfuscationFilesExist() { - return (Directory.Exists(TangleFileConsts.k_PrevOutputPath) && (Directory.GetFiles(TangleFileConsts.k_PrevOutputPath).Length > 0)); + return Directory.Exists(TangleFileConsts.k_PrevOutputPath) && (Directory.GetFiles(TangleFileConsts.k_PrevOutputPath).Length > 0); } internal static bool CheckBadObfuscationFilesExist() { - return (Directory.Exists(TangleFileConsts.k_BadOutputPath) && (Directory.GetFiles(TangleFileConsts.k_BadOutputPath).Length > 0)); + return Directory.Exists(TangleFileConsts.k_BadOutputPath) && (Directory.GetFiles(TangleFileConsts.k_BadOutputPath).Length > 0); } } } diff --git a/Editor/Obfuscation/Service/TangleObfuscator.cs b/Editor/Obfuscation/Service/TangleObfuscator.cs index cd45a03..1fc7a94 100644 --- a/Editor/Obfuscation/Service/TangleObfuscator.cs +++ b/Editor/Obfuscation/Service/TangleObfuscator.cs @@ -1,6 +1,6 @@ using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; namespace UnityEditor.Purchasing { @@ -23,10 +23,10 @@ public class InvalidOrderArray : Exception { } /// The obfucated public key public static byte[] Obfuscate(byte[] data, int[] order, out int rkey) { - var rnd = new System.Random(); - int key = rnd.Next(2, 255); - byte[] res = new byte[data.Length]; - int slices = data.Length / 20 + 1; + var rnd = new Random(); + var key = rnd.Next(2, 255); + var res = new byte[data.Length]; + var slices = data.Length / 20 + 1; if (order == null || order.Length < slices) { @@ -34,11 +34,11 @@ public static byte[] Obfuscate(byte[] data, int[] order, out int rkey) } Array.Copy(data, res, data.Length); - for (int i = 0; i < slices - 1; i++) + for (var i = 0; i < slices - 1; i++) { - int j = rnd.Next(i, slices - 1); + var j = rnd.Next(i, slices - 1); order[i] = j; - int sliceSize = 20; // prob should be configurable + var sliceSize = 20; // prob should be configurable var tmp = res.Skip(i * 20).Take(sliceSize).ToArray(); // tmp = res[i*20 .. slice] Array.Copy(res, j * 20, res, i * 20, sliceSize); // res[i] = res[j*20 .. slice] Array.Copy(tmp, 0, res, j * 20, sliceSize); // res[j] = tmp @@ -46,7 +46,7 @@ public static byte[] Obfuscate(byte[] data, int[] order, out int rkey) order[slices - 1] = slices - 1; rkey = key; - return res.Select(x => (byte)(x ^ key)).ToArray(); + return res.Select(x => (byte)(x ^ key)).ToArray(); } } } diff --git a/Editor/Obfuscation/UI/ObfuscatorWindow.cs b/Editor/Obfuscation/UI/ObfuscatorWindow.cs index 9557b7f..0f1ac91 100644 --- a/Editor/Obfuscation/UI/ObfuscatorWindow.cs +++ b/Editor/Obfuscation/UI/ObfuscatorWindow.cs @@ -59,7 +59,7 @@ internal class ObfuscatorWindow : RichEditorWindow static void Init() { // Get existing open window or if none, make a new one: - ObfuscatorWindow window = (ObfuscatorWindow)EditorWindow.GetWindow(typeof(ObfuscatorWindow)); + var window = (ObfuscatorWindow)GetWindow(typeof(ObfuscatorWindow)); window.titleContent.text = kLabelTitle; window.minSize = new Vector2(340, 180); window.Show(); @@ -83,7 +83,9 @@ void OnGUI() // Apple error message, if any if (!string.IsNullOrEmpty(m_AppleError)) + { GUILayout.Label(m_AppleError, m_ErrorStyle); + } // Google Play GUILayout.Label(kLabelGoogleKey, EditorStyles.boldLabel); @@ -102,9 +104,14 @@ void OnGUI() GUILayout.Label(kObfuscateKeyInstructions); if (!string.IsNullOrEmpty(m_GoogleError)) + { GUILayout.Label(m_GoogleError, m_ErrorStyle); + } + if (GUILayout.Button(kLabelGenerateGoogle)) + { ObfuscateSecrets(includeGoogle: true); + } GUILayout.Label(kDashboardInstructions); diff --git a/Editor/ProductCatalogEditor.cs b/Editor/ProductCatalogEditor.cs index 3dbae6b..c31a1c8 100644 --- a/Editor/ProductCatalogEditor.cs +++ b/Editor/ProductCatalogEditor.cs @@ -1,6 +1,6 @@ using System; -using System.IO; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using UnityEditor.Connect; @@ -16,7 +16,7 @@ public class ProductCatalogEditor : EditorWindow { private const bool kValidateDebugLog = false; - private static string[] kStoreKeys = + private static readonly string[] kStoreKeys = { AppleAppStore.Name, GooglePlay.Name, @@ -37,16 +37,15 @@ public class ProductCatalogEditor : EditorWindow [MenuItem(ProductCatalogEditorMenuPath, false, 200)] public static void ShowWindow() { - EditorWindow.GetWindow(typeof(ProductCatalogEditor)); + GetWindow(typeof(ProductCatalogEditor)); GenericEditorMenuItemClickEventSenderHelpers.SendIapMenuOpenCatalogEvent(); GameServicesEventSenderHelpers.SendTopMenuIapCatalogEvent(); } - private static GUIContent windowTitle = new GUIContent("IAP Catalog"); - private static List productEditors = new List(); - private static List toRemove = new List(); - private ProductCatalog catalog; + private static readonly GUIContent windowTitle = new GUIContent("IAP Catalog"); + private static readonly List productEditors = new List(); + private static readonly List toRemove = new List(); private Rect exportButtonRect; private ExporterValidationResults validation; @@ -65,7 +64,7 @@ public static void ShowWindow() private static TokenInfo kTokenInfo = new TokenInfo(); private static string kOrgId; private static object kAppStoreSettings; //UDP AppStoreSettings via Reflection - private static IDictionary kIapItems = new Dictionary(); + private static readonly IDictionary kIapItems = new Dictionary(); private static readonly bool s_udpAvailable = UdpSynchronizationApi.CheckUdpAvailability(); private static string kUdpErrorMsg = ""; @@ -80,7 +79,7 @@ internal static void MigrateProductCatalog() { try { - FileInfo file = new FileInfo(ProductCatalog.kCatalogPath); + var file = new FileInfo(ProductCatalog.kCatalogPath); // This will create the new product catalog file location, if it already exists, // this will not do anything. @@ -112,7 +111,7 @@ internal static bool DoesPrevCatalogPathExist() /// /// Property which gets the ProductCatalog instance which is being edited. /// - public ProductCatalog Catalog => catalog; + public ProductCatalog Catalog { get; private set; } /// /// Sets the results of the validation of catalog items upon export. @@ -126,7 +125,7 @@ public void SetCatalogValidationResults(ExporterValidationResults catalogResults if (productEditors.Count == itemResults.Count) { - for (int i = 0; i < productEditors.Count; ++i) + for (var i = 0; i < productEditors.Count; ++i) { productEditors[i].SetValidationResults(itemResults[i]); } @@ -135,8 +134,8 @@ public void SetCatalogValidationResults(ExporterValidationResults catalogResults void Awake() { - catalog = ProductCatalog.LoadDefaultCatalog(); - if (catalog.allProducts.Count == 0) + Catalog = ProductCatalog.LoadDefaultCatalog(); + if (Catalog.allProducts.Count == 0) { AddNewProduct(); // Start the catalog with one item } @@ -147,7 +146,7 @@ void OnEnable() titleContent = windowTitle; productEditors.Clear(); - foreach (var product in catalog.allProducts) + foreach (var product in Catalog.allProducts) { productEditors.Add(new ProductCatalogItemEditor(product)); } @@ -196,7 +195,7 @@ private void SetDirtyFlag() private void Save() { dirty = false; - File.WriteAllText(ProductCatalog.kCatalogPath, ProductCatalog.Serialize(catalog)); + File.WriteAllText(ProductCatalog.kCatalogPath, ProductCatalog.Serialize(Catalog)); AssetDatabase.ImportAsset(ProductCatalog.kCatalogPath); } @@ -231,10 +230,10 @@ void OnGUI() EditorGUILayout.EndScrollView(); EditorGUILayout.BeginVertical(); - float defaultLabelWidth = EditorGUIUtility.labelWidth; + var defaultLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = 315; - bool catalogHasProducts = !catalog.IsEmpty(); + var catalogHasProducts = !Catalog.IsEmpty(); if (catalogHasProducts) { ShowAndProcessCodelessAutoInitToggleGuis(); @@ -247,8 +246,8 @@ void OnGUI() EditorGUILayout.LabelField("Catalog Export"); - catalog.appleSKU = ShowEditTextFieldGuiAndGetValue("appleSKU", "Apple SKU:", catalog.appleSKU); - catalog.appleTeamID = ShowEditTextFieldGuiAndGetValue("appleTeamID", "Apple Team ID:", catalog.appleTeamID); + Catalog.appleSKU = ShowEditTextFieldGuiAndGetValue("appleSKU", "Apple SKU:", Catalog.appleSKU); + Catalog.appleTeamID = ShowEditTextFieldGuiAndGetValue("appleTeamID", "Apple Team ID:", Catalog.appleTeamID); if (EditorGUI.EndChangeCheck()) { @@ -273,7 +272,7 @@ void OnGUI() productEditors.RemoveAll(x => toRemove.Contains(x)); foreach (var editor in toRemove) { - catalog.Remove(editor.Item); + Catalog.Remove(editor.Item); } toRemove.Clear(); @@ -286,7 +285,7 @@ private void ShowAndProcessCodelessAutoInitToggleGuis() EditorGUILayout.Space(); ShowAndProcessIapAutoInitToggleGui(); - if (catalog.enableCodelessAutoInitialization) + if (Catalog.enableCodelessAutoInitialization) { ShowAndProcessUgsAutoInitToggleGui(); } @@ -299,16 +298,16 @@ private void ShowAndProcessIapAutoInitToggleGui() var newValue = EditorGUILayout.Toggle( new GUIContent("Automatically initialize UnityPurchasing (recommended)", "Automatically start Unity IAP if there are any products defined in this catalog. Uncheck this if you plan to initialize Unity IAP manually in your code."), - catalog.enableCodelessAutoInitialization); + Catalog.enableCodelessAutoInitialization); UpdateIapAutoInitValue(newValue); } private void UpdateIapAutoInitValue(bool newValue) { - if (newValue != catalog.enableCodelessAutoInitialization) + if (newValue != Catalog.enableCodelessAutoInitialization) { - catalog.enableCodelessAutoInitialization = newValue; + Catalog.enableCodelessAutoInitialization = newValue; GenericEditorClickCheckboxEventSenderHelpers.SendCatalogAutoInitToggleEvent(newValue); } @@ -321,16 +320,16 @@ private void ShowAndProcessUgsAutoInitToggleGui() "This initializes Unity Gaming Services with the default `production` environment.\n" + "This way of initializing Unity Gaming Services might not be compatible with all other services as they might require special initialization options.\n" + "If the use of initialization options is needed, Unity Gaming Services should be initialized with the coded API."), - catalog.enableUnityGamingServicesAutoInitialization); + Catalog.enableUnityGamingServicesAutoInitialization); UpdateUgsAutoInitValue(newValue); } private void UpdateUgsAutoInitValue(bool newValue) { - if (newValue != catalog.enableUnityGamingServicesAutoInitialization) + if (newValue != Catalog.enableUnityGamingServicesAutoInitialization) { - catalog.enableUnityGamingServicesAutoInitialization = newValue; + Catalog.enableUnityGamingServicesAutoInitialization = newValue; GenericEditorClickCheckboxEventSenderHelpers.SendCatalogUgsAutoInitToggleEvent(newValue); } @@ -382,19 +381,22 @@ private void AddNewProduct() } } - if (invalidIdsExist) return; + if (invalidIdsExist) + { + return; + } var newEditor = new ProductCatalogItemEditor(); newEditor.SetShouldBeMarked(false); productEditors.Add(newEditor); - catalog.Add(newEditor.Item); + Catalog.Add(newEditor.Item); } private void CheckForDuplicateIDs() { var ids = new HashSet(); var duplicates = new HashSet(); - foreach (var product in catalog.allProducts) + foreach (var product in Catalog.allProducts) { if (!string.IsNullOrEmpty(product.id) && ids.Contains(product.id)) { @@ -464,7 +466,7 @@ private static void EndErrorBlock(ExporterValidationResults validation, string f /// Whether or not the export was succesful. Always returns false if eraseExport is true. public static bool Export(string storeName, string folder, bool eraseExport) { - var editor = ScriptableObject.CreateInstance(typeof(ProductCatalogEditor)) as ProductCatalogEditor; + var editor = CreateInstance(typeof(ProductCatalogEditor)) as ProductCatalogEditor; return new ProductCatalogExportWindow(editor).Export(storeName, folder, eraseExport); } @@ -511,7 +513,7 @@ private void CheckApiUpdate() var newRequest = UdpSynchronizationApi.CreateGetOrgIdRequest(kTokenInfo.access_token, Application.cloudProjectId); - ReqStruct newReqStruct = new ReqStruct { request = newRequest, resp = new OrgIdResponse() }; + var newReqStruct = new ReqStruct { request = newRequest, resp = new OrgIdResponse() }; requestQueue.Enqueue(newReqStruct); } @@ -575,18 +577,9 @@ void TryParseErrorAsJson(string downloadedRawJson, long responseCode) try { var response = JsonUtility.FromJson(downloadedRawJson); - if (response?.message != null && response.details != null && response.details.Length != 0) - { - kUdpErrorMsg = string.Format("{0} : {1}", response.details[0].field, response.message); - } - else if (response?.message != null) - { - kUdpErrorMsg = response.message; - } - else - { - kUdpErrorMsg = $"Unknown Error, Please try again. Error code: {responseCode}"; - } + kUdpErrorMsg = response?.message != null && response.details != null && response.details.Length != 0 + ? string.Format("{0} : {1}", response.details[0].field, response.message) + : response?.message != null ? response.message : $"Unknown Error, Please try again. Error code: {responseCode}"; } catch (ArgumentException) { @@ -600,7 +593,7 @@ void TryParseErrorAsJson(string downloadedRawJson, long responseCode) void PrepareDeveloperInfo() { // Get Client ID - Type udpAppStoreSettings = AppStoreSettingsInterface.GetClassType(); + var udpAppStoreSettings = AppStoreSettingsInterface.GetClassType(); if (udpAppStoreSettings != null) { var assetPathProp = AppStoreSettingsInterface.GetAssetPathField(); @@ -640,14 +633,14 @@ public void GetAuthCode(T response) { var authCodePropertyInfo = response.GetType().GetProperty("AuthCode"); var exceptionPropertyInfo = response.GetType().GetProperty("Exception"); - string authCode = (string)authCodePropertyInfo.GetValue(response, null); - Exception exception = (Exception)exceptionPropertyInfo.GetValue(response, null); + var authCode = (string)authCodePropertyInfo.GetValue(response, null); + var exception = (Exception)exceptionPropertyInfo.GetValue(response, null); if (authCode != null) { var request = UdpSynchronizationApi.CreateGetAccessTokenRequest(authCode); - TokenInfo tokenInfoResp = new TokenInfo(); - ReqStruct reqStruct = new ReqStruct { request = request, resp = tokenInfoResp }; + var tokenInfoResp = new TokenInfo(); + var reqStruct = new ReqStruct { request = request, resp = tokenInfoResp }; requestQueue.Enqueue(reqStruct); } else @@ -673,7 +666,7 @@ public class ProductCatalogItemEditor private ExporterValidationResults validation; - private bool editorSupportsPayouts = false; + private readonly bool editorSupportsPayouts = false; private bool advancedVisible = true; private bool descriptionVisible = true; @@ -697,17 +690,17 @@ public class ProductCatalogItemEditor /// public string udpSyncErrorMsg = ""; - private List descriptionsToRemove = new List(); - private List payoutsToRemove = new List(); + private readonly List descriptionsToRemove = new List(); + private readonly List payoutsToRemove = new List(); /// /// Default constructor. Creates a new ProductCatalogItem to edit. /// public ProductCatalogItemEditor() { - this.Item = new ProductCatalogItem(); + Item = new ProductCatalogItem(); - editorSupportsPayouts = (null != typeof(ProductDefinition).GetProperty("payouts")); + editorSupportsPayouts = null != typeof(ProductDefinition).GetProperty("payouts"); } /// @@ -716,8 +709,8 @@ public ProductCatalogItemEditor() /// The description of the item being created. public ProductCatalogItemEditor(ProductCatalogItem description) { - this.Item = description; - editorSupportsPayouts = (null != typeof(ProductDefinition).GetProperty("payouts")); + Item = description; + editorSupportsPayouts = null != typeof(ProductDefinition).GetProperty("payouts"); } /// @@ -725,10 +718,10 @@ public ProductCatalogItemEditor(ProductCatalogItem description) /// public void OnGUI() { - GUIStyle s = new GUIStyle(EditorStyles.foldout); + var s = new GUIStyle(EditorStyles.foldout); var box = EditorGUILayout.BeginVertical(); - Rect rect = new Rect(box.xMax - EditorGUIUtility.singleLineHeight - 2, box.yMin, EditorGUIUtility.singleLineHeight + 2, EditorGUIUtility.singleLineHeight); + var rect = new Rect(box.xMax - EditorGUIUtility.singleLineHeight - 2, box.yMin, EditorGUIUtility.singleLineHeight + 2, EditorGUIUtility.singleLineHeight); if (GUI.Button(rect, "x") && EditorUtility.DisplayDialog("Delete Product?", "Are you sure you want to delete this product?", "Delete", "Do Not Delete")) { toRemove.Add(this); @@ -823,7 +816,7 @@ public void OnGUI() { EditorGUI.indentLevel++; - int payoutIndex = 1; + var payoutIndex = 1; foreach (var payout in Item.Payouts) { var payoutBox = EditorGUILayout.BeginVertical(); @@ -871,7 +864,7 @@ public void OnGUI() if (storeIDsVisible) { EditorGUI.indentLevel++; - foreach (string storeKey in kStoreKeys) + foreach (var storeKey in kStoreKeys) { var newStoreID = ShowEditTextFieldGuiWithValidationErrorBlockAndGetValue("storeID." + storeKey, storeKey, Item.GetStoreID(storeKey)); Item.SetStoreID(storeKey, newStoreID); @@ -952,25 +945,19 @@ public void OnGUI() ? string.Empty : Item.udpPrice.value.ToString()); - decimal priceDecimal; - if (decimal.TryParse(priceStr, out priceDecimal)) - { - Item.udpPrice.value = priceDecimal; - } - else - { - Item.udpPrice.value = 0; - } + Item.udpPrice.value = decimal.TryParse(priceStr, out var priceDecimal) ? priceDecimal : 0; EndErrorBlock(validation, "udpPrice"); if (GUILayout.Button("Sync to UDP")) { udpSyncErrorMsg = ""; - IapItem iapItem = new IapItem(); - iapItem.consumable = Item.type == ProductType.Consumable; - iapItem.slug = Item.GetStoreID(UDP.Name) ?? Item.id; - iapItem.name = Item.defaultDescription.Title; + var iapItem = new IapItem + { + consumable = Item.type == ProductType.Consumable, + slug = Item.GetStoreID(UDP.Name) ?? Item.id, + name = Item.defaultDescription.Title + }; iapItem.properties.description = Item.defaultDescription.Description; iapItem.priceSets.PurchaseFee.priceMap.DEFAULT.Add(new PriceDetail { @@ -1100,15 +1087,7 @@ void ShowAndProcessGoogleConfigGui() BeginErrorBlock(validation, fieldName); var priceStr = ShowEditTextFieldGuiAndGetValue(fieldName, "Price:", Item.googlePrice == null || Item.googlePrice.value == 0 ? string.Empty : Item.googlePrice.value.ToString()); - decimal priceDecimal; - if (decimal.TryParse(priceStr, out priceDecimal)) - { - Item.googlePrice.value = priceDecimal; - } - else - { - Item.googlePrice.value = 0; - } + Item.googlePrice.value = decimal.TryParse(priceStr, out var priceDecimal) ? priceDecimal : 0; Item.pricingTemplateID = ShowEditTextFieldGuiAndGetValue("googlePriceTemplate", "Pricing Template:", Item.pricingTemplateID); EndErrorBlock(validation, fieldName); @@ -1139,7 +1118,7 @@ void ShowAndProcessAppleConfigGui() EditorGUIUtility.singleLineHeight); if (GUI.Button(screenshotButtonRect, new GUIContent("Select a screenshot", "Required for Apple XML Delivery."))) { - string selectedPath = EditorUtility.OpenFilePanel("Select a screenshot", "", ""); + var selectedPath = EditorUtility.OpenFilePanel("Select a screenshot", "", ""); if (selectedPath != null) { Item.screenshotPath = selectedPath; @@ -1213,12 +1192,12 @@ private bool DescriptionEditorGUI(LocalizedProductDescription description, bool description.Description = ShowEditTextFieldGuiWithValidationErrorBlockAndGetValue(fieldValidationPrefix + ".Description", "Description:", description.Description); var removeButtonRect = new Rect(box.xMax - removeButtonWidth, box.yMin, removeButtonWidth, EditorGUIUtility.singleLineHeight); - var remove = (showRemoveButton + var remove = showRemoveButton && GUI.Button(removeButtonRect, "x") && EditorUtility.DisplayDialog("Delete Translation?", "Are you sure you want to delete this translation?", "Delete", - "Do Not Delete")); + "Do Not Delete"); EditorGUILayout.EndVertical(); return remove; } @@ -1273,8 +1252,16 @@ double ShowEditDoubleFieldGuiAndGetValue(string fieldName, string label, double private static string TruncateString(string s, int len) { - if (string.IsNullOrEmpty(s)) return s; - if (len < 0) return string.Empty; + if (string.IsNullOrEmpty(s)) + { + return s; + } + + if (len < 0) + { + return string.Empty; + } + return s.Substring(0, Math.Min(s.Length, len)); } } @@ -1289,8 +1276,8 @@ public class ProductCatalogExportWindow : PopupWindowContent /// public const float kWidth = 200f; - private ProductCatalogEditor editor; - private List exporters = new List(); + private readonly ProductCatalogEditor editor; + private readonly List exporters = new List(); /// /// Constructor taking an instance of ProductCatalogEditor to export contents from. @@ -1342,7 +1329,7 @@ public override void OnGUI(Rect rect) private bool Validate(IProductCatalogExporter exporter, out ExporterValidationResults catalogValidation, out List itemValidation, bool debug = false) { - bool valid = true; + var valid = true; catalogValidation = exporter.Validate(editor.Catalog); valid = valid && catalogValidation.Valid; itemValidation = new List(); @@ -1356,27 +1343,34 @@ private bool Validate(IProductCatalogExporter exporter, out ExporterValidationRe if (debug) { - Action DebugResults = - (string name, ExporterValidationResults r) => + void DebugResults(string name, ExporterValidationResults r) + { + if (!r.Valid || r.warnings.Count != 0) { - if (!r.Valid || r.warnings.Count != 0) Debug.LogWarning(name + ", Valid = " + r.Valid); - foreach (var m in r.errors) - { - Debug.LogWarning("errors " + m); - } + Debug.LogWarning(name + ", Valid = " + r.Valid); + } - foreach (var m in r.fieldErrors) - { - Debug.LogWarning("fieldErrors " + m); - } + foreach (var m in r.errors) + { + Debug.LogWarning("errors " + m); + } - foreach (var m in r.warnings) - { - Debug.LogWarning("warnings " + m); - } - }; + foreach (var m in r.fieldErrors) + { + Debug.LogWarning("fieldErrors " + m); + } + + foreach (var m in r.warnings) + { + Debug.LogWarning("warnings " + m); + } + } + + if (!valid) + { + Debug.LogWarning("Product Catalog Export Overall Result: valid " + valid); + } - if (!valid) Debug.LogWarning("Product Catalog Export Overall Result: valid " + valid); DebugResults("CatalogValidation", catalogValidation); foreach (var r in itemValidation) { @@ -1389,10 +1383,8 @@ private bool Validate(IProductCatalogExporter exporter, out ExporterValidationRe private void Export(IProductCatalogExporter exporter) { - ExporterValidationResults catalogValidation; - List itemValidation; - var valid = Validate(exporter, out catalogValidation, out itemValidation, kValidateDebugLog); + var valid = Validate(exporter, out var catalogValidation, out var itemValidation, kValidateDebugLog); editor.SetCatalogValidationResults(catalogValidation, itemValidation); if (valid) @@ -1471,8 +1463,8 @@ private void ExportHelper(IProductCatalogExporter exporter, string path) { foreach (var fileToCopy in exporter.FilesToCopy) { - string targetPath = Path.Combine(Path.GetDirectoryName(path), Path.GetFileName(fileToCopy)); - FileInfo fileInfo = new FileInfo(fileToCopy); + var targetPath = Path.Combine(Path.GetDirectoryName(path), Path.GetFileName(fileToCopy)); + var fileInfo = new FileInfo(fileToCopy); fileInfo.CopyTo(targetPath, true); } } @@ -1490,7 +1482,7 @@ internal bool Export(string storeName, string folder, bool justEraseExport) { var catalog = editor.Catalog; // This may be normalized before export - IProductCatalogExporter exporter = exporters.Single(e => e.StoreName == storeName); + var exporter = exporters.Single(e => e.StoreName == storeName); if (exporter == null) { Debug.LogErrorFormat("Unable to export {0} Product Catalog. Export is unsupported for this store.", @@ -1512,9 +1504,7 @@ internal bool Export(string storeName, string folder, bool justEraseExport) return false; } - ExporterValidationResults catalogValidation; - List itemValidation; - var valid = Validate(exporter, out catalogValidation, out itemValidation, kValidateDebugLog); + var valid = Validate(exporter, out var catalogValidation, out var itemValidation, kValidateDebugLog); if (!valid) { @@ -1524,7 +1514,7 @@ internal bool Export(string storeName, string folder, bool justEraseExport) catalog = exporter.NormalizeToType(catalog); } - bool wrote = false; + var wrote = false; if (!string.IsNullOrEmpty(path)) { @@ -1554,10 +1544,7 @@ public class ExporterValidationResults /// /// Property that checks if the export results are valid. /// - public bool Valid - { - get { return (errors.Count == 0 && fieldErrors.Count == 0); } - } + public bool Valid => errors.Count == 0 && fieldErrors.Count == 0; /// /// The list of errors. diff --git a/Editor/RichEditorWindow.cs b/Editor/RichEditorWindow.cs index 795bf26..2398f72 100644 --- a/Editor/RichEditorWindow.cs +++ b/Editor/RichEditorWindow.cs @@ -17,10 +17,10 @@ internal RichEditorWindow() internal void GUILink(string linkText, string url) { - m_LightLinkIcon = m_LightLinkIcon ?? AssetDatabase.LoadAssetAtPath(kLightLinkIconPath); - m_DarkLinkIcon = m_DarkLinkIcon ?? AssetDatabase.LoadAssetAtPath(kDarkLinkIconPath); + m_LightLinkIcon ??= AssetDatabase.LoadAssetAtPath(kLightLinkIconPath); + m_DarkLinkIcon ??= AssetDatabase.LoadAssetAtPath(kDarkLinkIconPath); - m_LinkStyle = m_LinkStyle ?? new GUIStyle(); + m_LinkStyle ??= new GUIStyle(); m_LinkStyle.normal.textColor = EditorGUIUtility.isProSkin ? Color.cyan : Color.blue; m_LinkStyle.contentOffset = new Vector2(6, 0); // Indent like other labels @@ -31,14 +31,18 @@ internal void GUILink(string linkText, string url) var linkRect = GUILayoutUtility.GetLastRect(); if (linkIcon != null) + { GUI.Label(new Rect(linkSize.x, linkRect.y, linkRect.height, linkRect.height), linkIcon); + } else { Debug.LogWarning("Cannot get icon: " + kLightLinkIconPath); } if (Event.current.type == EventType.MouseUp && linkRect.Contains(Event.current.mousePosition)) + { Application.OpenURL(url); + } } } diff --git a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs index 1c438ad..ae81ced 100644 --- a/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs +++ b/Editor/ServiceProjectSettings/Presenter/BasePurchasingState.cs @@ -11,9 +11,11 @@ internal abstract class BasePurchasingState : SimpleStateMachine.State protected BasePurchasingState(string stateName, SimpleStateMachine stateMachine) : base(stateName, stateMachine) { - m_UIBlocks = new List(); - m_UIBlocks.Add(PlatformsAndStoresServiceSettingsBlock.CreateStateSpecificBlock(IsEnabled())); - m_UIBlocks.Add(new AnalyticsWarningSettingsBlock()); + m_UIBlocks = new List + { + PlatformsAndStoresServiceSettingsBlock.CreateStateSpecificBlock(IsEnabled()), + new AnalyticsWarningSettingsBlock() + }; } internal List GetStateUI() diff --git a/Editor/ServiceProjectSettings/PurchasingServiceEnabler.cs b/Editor/ServiceProjectSettings/PurchasingServiceEnabler.cs index 588909a..1349a8f 100644 --- a/Editor/ServiceProjectSettings/PurchasingServiceEnabler.cs +++ b/Editor/ServiceProjectSettings/PurchasingServiceEnabler.cs @@ -15,10 +15,7 @@ internal class PurchasingServiceEnabler : EditorGameServiceFlagEnabler const string k_ServiceFlagName = "purchasing"; const string k_LegacyEnabledSettingName = "Purchasing"; - protected override string FlagName - { - get { return k_ServiceFlagName; } - } + protected override string FlagName => k_ServiceFlagName; protected override void EnableLocalSettings() { @@ -42,7 +39,7 @@ static void SetLegacyEnabledSetting(bool value) var setCloudServiceEnabledMethod = playerSettingsType.GetMethod("SetCloudServiceEnabled", BindingFlags.Static | BindingFlags.NonPublic); if (setCloudServiceEnabledMethod != null) { - setCloudServiceEnabledMethod.Invoke(null, new object[] {k_LegacyEnabledSettingName, value}); + setCloudServiceEnabledMethod.Invoke(null, new object[] { k_LegacyEnabledSettingName, value }); } } } @@ -69,7 +66,7 @@ static bool GetLegacyEnabledSetting() var getCloudServiceEnabledMethod = playerSettingsType.GetMethod("GetCloudServiceEnabled", BindingFlags.Static | BindingFlags.NonPublic); if (getCloudServiceEnabledMethod != null) { - var enabledStateResult = getCloudServiceEnabledMethod.Invoke(null, new object[] {k_LegacyEnabledSettingName}); + var enabledStateResult = getCloudServiceEnabledMethod.Invoke(null, new object[] { k_LegacyEnabledSettingName }); isEnabled = Convert.ToBoolean(enabledStateResult); } } diff --git a/Editor/ServiceProjectSettings/PurchasingSettingsProvider.cs b/Editor/ServiceProjectSettings/PurchasingSettingsProvider.cs index d3cd1cf..9fc2163 100644 --- a/Editor/ServiceProjectSettings/PurchasingSettingsProvider.cs +++ b/Editor/ServiceProjectSettings/PurchasingSettingsProvider.cs @@ -12,8 +12,7 @@ internal class PurchasingSettingsProvider : EditorGameServiceSettingsProvider const string k_Title = "In-App Purchases"; const string k_Description = "Simplify cross platform In-App Purchasing"; - - PurchasingGameService m_Service; + readonly PurchasingGameService m_Service; bool m_CallbacksInitialized; SimpleStateMachine m_StateMachine; @@ -82,7 +81,7 @@ void RefreshDetailUI() var clonedContainer = SettingsUIUtils.CloneUIFromTemplate(UIResourceUtils.purchasingServicesRootUxmlPath); rootVisualElement.Add(clonedContainer); - var uiState = (BasePurchasingState) m_StateMachine.currentState; + var uiState = (BasePurchasingState)m_StateMachine.currentState; foreach (var uiStateElement in uiState.GetStateUI()) { diff --git a/Editor/ServiceProjectSettings/Service/GoogleConfigService.cs b/Editor/ServiceProjectSettings/Service/GoogleConfigService.cs index 3ee6c00..558ac12 100644 --- a/Editor/ServiceProjectSettings/Service/GoogleConfigService.cs +++ b/Editor/ServiceProjectSettings/Service/GoogleConfigService.cs @@ -2,15 +2,13 @@ namespace UnityEditor.Purchasing { internal class GoogleConfigService { - GoogleConfigurationData m_GoogleConfigData; - static GoogleConfigService m_Instance; - internal GoogleConfigurationData GoogleConfigData => m_GoogleConfigData; + internal GoogleConfigurationData GoogleConfigData { get; } GoogleConfigService() { - m_GoogleConfigData = new GoogleConfigurationData(); + GoogleConfigData = new GoogleConfigurationData(); } internal static GoogleConfigService Instance() diff --git a/Editor/ServiceProjectSettings/Service/Networking/GoogleConfigurationWebRequests.cs b/Editor/ServiceProjectSettings/Service/Networking/GoogleConfigurationWebRequests.cs index 1dae530..dd75c28 100644 --- a/Editor/ServiceProjectSettings/Service/Networking/GoogleConfigurationWebRequests.cs +++ b/Editor/ServiceProjectSettings/Service/Networking/GoogleConfigurationWebRequests.cs @@ -16,14 +16,12 @@ class GoogleConfigurationWebRequests const string k_AuthHeaderValueFormat = "Basic {0}"; const string k_ContentHeaderName = "Content-Type"; const string k_ContentHeaderValue = "application/json;charset=UTF-8"; - - IWebRequestInternal m_WebRequest = new CloudProjectWebRequest(); + readonly IWebRequestInternal m_WebRequest = new CloudProjectWebRequest(); UnityWebRequest m_GetGoogleKeyRequest; - GoogleConfigurationData m_PurchasingRemoteDataRef; - - Action m_GetGooglePlayKeyCallback; - Action m_SetGooglePlayKeyCallback; + readonly GoogleConfigurationData m_PurchasingRemoteDataRef; + readonly Action m_GetGooglePlayKeyCallback; + readonly Action m_SetGooglePlayKeyCallback; internal GoogleConfigurationWebRequests(GoogleConfigurationData remoteData, Action onGetGooglePlayKey, Action onSetGooglePlayKey) { @@ -90,7 +88,7 @@ void OnGetGooglePlayKey(AsyncOperation getKeyOperation) void FetchGooglePlayKeyFromRequest() { - string googlePlayKey = ""; + var googlePlayKey = ""; if (IsGoogleKeyRequestResultSuccess()) { try @@ -166,21 +164,11 @@ void OnSubmitGooglePlayKey(AsyncOperation pushKeyOperation) void HandleCompletedSubmitResponse(UnityWebRequest completedRequest) { - GooglePlayRevenueTrackingKeyState keyState; - - if (completedRequest.IsResultTransferSuccess()) - { - keyState = GooglePlayRevenueTrackingKeyState.Verified; - } - else if (completedRequest.IsResultProtocolError()) - { - keyState = InterpretKeyStateFromProtocolError(completedRequest.responseCode); - } - else - { - keyState = GooglePlayRevenueTrackingKeyState.InvalidFormat; - } - + var keyState = completedRequest.IsResultTransferSuccess() + ? GooglePlayRevenueTrackingKeyState.Verified + : completedRequest.IsResultProtocolError() + ? InterpretKeyStateFromProtocolError(completedRequest.responseCode) + : GooglePlayRevenueTrackingKeyState.InvalidFormat; m_SetGooglePlayKeyCallback(keyState); } diff --git a/Editor/ServiceProjectSettings/Service/Networking/NetworkingUtils.cs b/Editor/ServiceProjectSettings/Service/Networking/NetworkingUtils.cs index 8d4023e..8c198c9 100644 --- a/Editor/ServiceProjectSettings/Service/Networking/NetworkingUtils.cs +++ b/Editor/ServiceProjectSettings/Service/Networking/NetworkingUtils.cs @@ -20,8 +20,7 @@ internal static string GetValueFromJsonDictionary(string rawJson, string key) { var container = (Dictionary)MiniJson.JsonDecode(rawJson); - object value; - container.TryGetValue(key, out value); + container.TryGetValue(key, out var value); return value as string; } } diff --git a/Editor/ServiceProjectSettings/SimpleStateMachine.cs b/Editor/ServiceProjectSettings/SimpleStateMachine.cs index 4636459..4eabc8b 100644 --- a/Editor/ServiceProjectSettings/SimpleStateMachine.cs +++ b/Editor/ServiceProjectSettings/SimpleStateMachine.cs @@ -12,8 +12,8 @@ namespace UnityEditor.Purchasing /// class SimpleStateMachine { - HashSet m_Events = new HashSet(); - Dictionary m_StateByName = new Dictionary(); + readonly HashSet m_Events = new HashSet(); + readonly Dictionary m_StateByName = new Dictionary(); bool m_Initialized; /// @@ -73,7 +73,7 @@ public void AddEvent(T simpleStateMachineEvent) public bool EventExists(T simpleStateMachineEvent) { - foreach (T knownEvent in m_Events) + foreach (var knownEvent in m_Events) { if (knownEvent.Equals(simpleStateMachineEvent)) { @@ -164,7 +164,7 @@ public void ProcessEvent(T simpleStateMachineEvent) /// internal class State { - List m_ActionForEvent = new List(); + readonly List m_ActionForEvent = new List(); /// /// Access to the state machine. Mostly to GetStateByName when transitioning from one state to another diff --git a/Editor/ServiceProjectSettings/UI/Views/AppleConfigurationSettingsBlock.cs b/Editor/ServiceProjectSettings/UI/Views/AppleConfigurationSettingsBlock.cs index 41ee288..e4c3f89 100644 --- a/Editor/ServiceProjectSettings/UI/Views/AppleConfigurationSettingsBlock.cs +++ b/Editor/ServiceProjectSettings/UI/Views/AppleConfigurationSettingsBlock.cs @@ -4,13 +4,11 @@ namespace UnityEditor.Purchasing { class AppleConfigurationSettingsBlock : IPurchasingSettingsUIBlock { - VisualElement m_AppleConfigurationBlock; - - AppleObfuscatorSection m_ObfuscatorSection; + readonly VisualElement m_AppleConfigurationBlock; + readonly AppleObfuscatorSection m_ObfuscatorSection; VisualElement m_ConfigurationBlock; - - string m_AppleErrorMessage; - string m_GoogleErrorMessage; + readonly string m_AppleErrorMessage; + readonly string m_GoogleErrorMessage; internal AppleConfigurationSettingsBlock() { diff --git a/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs b/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs index 598b789..1071ef5 100644 --- a/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs +++ b/Editor/ServiceProjectSettings/UI/Views/GooglePlayConfigurationSettingsBlock.cs @@ -19,18 +19,16 @@ internal class GooglePlayConfigurationSettingsBlock : IPurchasingSettingsUIBlock const string k_GooglePlayKeyBtnUpdateLabel = "Update"; const string k_GooglePlayKeyBtnVerifyLabel = "Verify"; - - GoogleConfigurationData m_GooglePlayDataRef; - GoogleConfigurationWebRequests m_WebRequests; + readonly GoogleConfigurationData m_GooglePlayDataRef; + readonly GoogleConfigurationWebRequests m_WebRequests; VisualElement m_ConfigurationBlock; - - GoogleObfuscatorSection m_ObfuscatorSection; + readonly GoogleObfuscatorSection m_ObfuscatorSection; internal GooglePlayConfigurationSettingsBlock() { m_GooglePlayDataRef = GoogleConfigService.Instance().GoogleConfigData; - m_WebRequests = new GoogleConfigurationWebRequests(m_GooglePlayDataRef, this.OnGetGooglePlayKey, this.OnUpdateGooglePlayKey); + m_WebRequests = new GoogleConfigurationWebRequests(m_GooglePlayDataRef, OnGetGooglePlayKey, OnUpdateGooglePlayKey); m_ObfuscatorSection = new GoogleObfuscatorSection(m_GooglePlayDataRef); } @@ -115,14 +113,9 @@ void ToggleUpdateButtonDisplay() var updateGooglePlayKeyBtn = m_ConfigurationBlock.Q