diff --git a/nuget/readme.txt b/nuget/readme.txt index e621428..5ceb5f5 100644 --- a/nuget/readme.txt +++ b/nuget/readme.txt @@ -1,5 +1,13 @@ In-App Billing Plugin for .NET MAUI, Xamarin, & Windows +Version 7.0+ - Major Android updates +1.) You must compile and target against Android 12 or higher +2.) Android: Now using Android Billing v6 +3.) Android: Major changes to Android product details, subscriptions, and more + +Please read through: https://developer.android.com/google/play/billing/migrate-gpblv6 + + Version 5.0+ has significant updates! 1.) We have removed IInAppBillingVerifyPurchase from all methods. All data required to handle this yourself is returned. 2.) iOS ReceiptURL data is avaialble via ReceiptData diff --git a/src/InAppBillingTests/InAppBillingTests.Mac/InAppBillingTests.Mac.csproj b/src/InAppBillingTests/InAppBillingTests.Mac/InAppBillingTests.Mac.csproj index f4816b3..04d3e4f 100644 --- a/src/InAppBillingTests/InAppBillingTests.Mac/InAppBillingTests.Mac.csproj +++ b/src/InAppBillingTests/InAppBillingTests.Mac/InAppBillingTests.Mac.csproj @@ -95,10 +95,6 @@ {6D4D9135-F225-4626-A9CE-32BDF97AEA89} InAppBillingTests - - {C570E25E-259F-4D4C-88F0-B2982815192D} - Plugin.InAppBilling - \ No newline at end of file diff --git a/src/InAppBillingTests/InAppBillingTests.iOS/InAppBillingTests.iOS.csproj b/src/InAppBillingTests/InAppBillingTests.iOS/InAppBillingTests.iOS.csproj index 24f89de..b6b5bb7 100644 --- a/src/InAppBillingTests/InAppBillingTests.iOS/InAppBillingTests.iOS.csproj +++ b/src/InAppBillingTests/InAppBillingTests.iOS/InAppBillingTests.iOS.csproj @@ -159,9 +159,5 @@ {6D4D9135-F225-4626-A9CE-32BDF97AEA89} InAppBillingTests - - {C570E25E-259F-4D4C-88F0-B2982815192D} - Plugin.InAppBilling - \ No newline at end of file diff --git a/src/Plugin.InAppBilling/Converters.android.cs b/src/Plugin.InAppBilling/Converters.android.cs index 134ec7d..d3c7132 100644 --- a/src/Plugin.InAppBilling/Converters.android.cs +++ b/src/Plugin.InAppBilling/Converters.android.cs @@ -17,9 +17,9 @@ internal static InAppBillingPurchase ToIABPurchase(this Purchase purchase) Signature = purchase.Signature, IsAcknowledged = purchase.IsAcknowledged, Payload = purchase.DeveloperPayload, - ProductId = purchase.Skus.FirstOrDefault(), + ProductId = purchase.Products?.FirstOrDefault(), Quantity = purchase.Quantity, - ProductIds = purchase.Skus, + ProductIds = purchase.Products, PurchaseToken = purchase.PurchaseToken, TransactionDateUtc = DateTimeOffset.FromUnixTimeMilliseconds(purchase.PurchaseTime).DateTime, ObfuscatedAccountId = purchase.AccountIdentifiers?.ObfuscatedAccountId, @@ -44,9 +44,9 @@ internal static InAppBillingPurchase ToIABPurchase(this PurchaseHistoryRecord pu OriginalJson = purchase.OriginalJson, Signature = purchase.Signature, Payload = purchase.DeveloperPayload, - ProductId = purchase.Skus.FirstOrDefault(), + ProductId = purchase.Products?.FirstOrDefault(), Quantity = purchase.Quantity, - ProductIds = purchase.Skus, + ProductIds = purchase.Products, PurchaseToken = purchase.PurchaseToken, TransactionDateUtc = DateTimeOffset.FromUnixTimeMilliseconds(purchase.PurchaseTime).DateTime, State = PurchaseState.Unknown, @@ -54,27 +54,40 @@ internal static InAppBillingPurchase ToIABPurchase(this PurchaseHistoryRecord pu }; } - internal static InAppBillingProduct ToIAPProduct(this SkuDetails product) + internal static InAppBillingProduct ToIAPProduct(this ProductDetails product) { + var oneTime = product.GetOneTimePurchaseOfferDetails(); + var subs = product.GetSubscriptionOfferDetails()?.Select(s => new SubscriptionOfferDetail + { + BasePlanId = s.BasePlanId, + OfferId = s.OfferId, + OfferTags = s.OfferTags?.ToList(), + OfferToken = s.OfferToken, + PricingPhases = s?.PricingPhases?.PricingPhaseList?.Select(p => + new PricingPhase + { + BillingCycleCount = p.BillingCycleCount, + BillingPeriod = p.BillingPeriod, + FormattedPrice = p.FormattedPrice, + PriceAmountMicros = p.PriceAmountMicros, + PriceCurrencyCode = p.PriceCurrencyCode, + RecurrenceMode = p.RecurrenceMode + }).ToList() + }).ToList(); + + var firstSub = subs?.FirstOrDefault()?.PricingPhases?.FirstOrDefault(); + return new InAppBillingProduct { Name = product.Title, Description = product.Description, - CurrencyCode = product.PriceCurrencyCode, - LocalizedPrice = product.Price, - ProductId = product.Sku, - MicrosPrice = product.PriceAmountMicros, + CurrencyCode = oneTime?.PriceCurrencyCode ?? firstSub?.PriceCurrencyCode, + LocalizedPrice = oneTime?.FormattedPrice ?? firstSub?.FormattedPrice, + ProductId = product.ProductId, + MicrosPrice = oneTime?.PriceAmountMicros ?? firstSub?.PriceAmountMicros ?? 0, AndroidExtras = new InAppBillingProductAndroidExtras { - SubscriptionPeriod = product.SubscriptionPeriod, - LocalizedIntroductoryPrice = product.IntroductoryPrice, - MicrosIntroductoryPrice = product.IntroductoryPriceAmountMicros, - FreeTrialPeriod = product.FreeTrialPeriod, - IconUrl = product.IconUrl, - IntroductoryPriceCycles = product.IntroductoryPriceCycles, - IntroductoryPricePeriod = product.IntroductoryPricePeriod, - MicrosOriginalPriceAmount = product.OriginalPriceAmountMicros, - OriginalPrice = product.OriginalPrice + SubscriptionOfferDetails = subs } }; } diff --git a/src/Plugin.InAppBilling/InAppBilling.android.cs b/src/Plugin.InAppBilling/InAppBilling.android.cs index 8efbf84..3d79c1b 100644 --- a/src/Plugin.InAppBilling/InAppBilling.android.cs +++ b/src/Plugin.InAppBilling/InAppBilling.android.cs @@ -3,12 +3,10 @@ using System.Linq; using System.Threading.Tasks; using Android.App; -using Java.Security; -using Java.Security.Spec; -using Java.Lang; -using System.Text; using Android.BillingClient.Api; using Android.Content; +using static Android.BillingClient.Api.BillingClient; +using BillingResponseCode = Android.BillingClient.Api.BillingResponseCode; #if NET using Microsoft.Maui.ApplicationModel; #else @@ -17,10 +15,10 @@ namespace Plugin.InAppBilling { - /// - /// Implementation for Feature - /// - [Preserve(AllMembers = true)] + /// + /// Implementation for Feature + /// + [Preserve(AllMembers = true)] public class InAppBillingImplementation : BaseInAppBilling { /// @@ -33,10 +31,10 @@ public class InAppBillingImplementation : BaseInAppBilling /// This is set from the MainApplication.cs file that was laid down by the plugin /// /// The context. - Activity Activity => - Platform.CurrentActivity ?? throw new NullReferenceException("Current Activity is null, ensure that the MainActivity.cs file is configuring Xamarin.Essentials in your source code so the In App Billing can use it."); + static Activity Activity => + Platform.CurrentActivity ?? throw new NullReferenceException("Current Activity is null, ensure that the MainActivity.cs file is configuring Xamarin.Essentials/.NET MAUI in your source code so the In App Billing can use it."); - Context Context => Android.App.Application.Context; + static Context Context => Application.Context; /// /// Default Constructor for In App Billing Implementation on Android @@ -67,7 +65,7 @@ public override Task ConnectAsync(bool enablePendingPurchases = true) tcsConnect?.TrySetCanceled(); tcsConnect = new TaskCompletionSource(); - BillingClientBuilder = BillingClient.NewBuilder(Context); + BillingClientBuilder = NewBuilder(Context); BillingClientBuilder.SetListener(OnPurchasesUpdated); if (enablePendingPurchases) BillingClient = BillingClientBuilder.EnablePendingPurchases().Build(); @@ -146,47 +144,51 @@ public async override Task> GetProductInfoAsync var skuType = itemType switch { - ItemType.InAppPurchase => BillingClient.SkuType.Inapp, - ItemType.InAppPurchaseConsumable => BillingClient.SkuType.Inapp, - _ => BillingClient.SkuType.Subs + ItemType.InAppPurchase => ProductType.Inapp, + ItemType.InAppPurchaseConsumable => ProductType.Inapp, + _ => ProductType.Subs }; - if(skuType == BillingClient.SkuType.Subs) + if (skuType == ProductType.Subs) { - var result = BillingClient.IsFeatureSupported(BillingClient.FeatureType.Subscriptions); + var result = BillingClient.IsFeatureSupported(FeatureType.Subscriptions); ParseBillingResult(result); } - var skuDetailsParams = SkuDetailsParams.NewBuilder() - .SetType(skuType) - .SetSkusList(productIds) - .Build(); - var skuDetailsResult = await BillingClient.QuerySkuDetailsAsync(skuDetailsParams); - ParseBillingResult(skuDetailsResult?.Result); + var productList = productIds.Select(p => QueryProductDetailsParams.Product.NewBuilder() + .SetProductType(skuType) + .SetProductId(p) + .Build()).ToList(); + + var skuDetailsParams = QueryProductDetailsParams.NewBuilder().SetProductList(productList); + var skuDetailsResult = await BillingClient.QueryProductDetailsAsync(skuDetailsParams.Build()); + ParseBillingResult(skuDetailsResult?.Result, IgnoreInvalidProducts); - return skuDetailsResult.SkuDetails.Select(product => product.ToIAPProduct()); + + return skuDetailsResult.ProductDetails.Select(product => product.ToIAPProduct()); } - - public override Task> GetPurchasesAsync(ItemType itemType) + + public override async Task> GetPurchasesAsync(ItemType itemType) { if (BillingClient == null) throw new InAppBillingPurchaseException(PurchaseError.ServiceUnavailable, "You are not connected to the Google Play App store."); var skuType = itemType switch { - ItemType.InAppPurchase => BillingClient.SkuType.Inapp, - ItemType.InAppPurchaseConsumable => BillingClient.SkuType.Inapp, - _ => BillingClient.SkuType.Subs + ItemType.InAppPurchase => ProductType.Inapp, + ItemType.InAppPurchaseConsumable => ProductType.Inapp, + _ => ProductType.Subs }; - var purchasesResult = BillingClient.QueryPurchases(skuType); + var query = QueryPurchasesParams.NewBuilder().SetProductType(skuType).Build(); + var purchasesResult = await BillingClient.QueryPurchasesAsync(query); - ParseBillingResult(purchasesResult.BillingResult); + ParseBillingResult(purchasesResult.Result); - return Task.FromResult(purchasesResult.PurchasesList.Select(p => p.ToIABPurchase())); + return purchasesResult.Purchases.Select(p => p.ToIABPurchase()); } /// @@ -201,11 +203,12 @@ public override async Task> GetPurchasesHistor var skuType = itemType switch { - ItemType.InAppPurchase => BillingClient.SkuType.Inapp, - ItemType.InAppPurchaseConsumable => BillingClient.SkuType.Inapp, - _ => BillingClient.SkuType.Subs + ItemType.InAppPurchase => ProductType.Inapp, + ItemType.InAppPurchaseConsumable => ProductType.Inapp, + _ => ProductType.Subs }; + //TODO: Binding needs updated var purchasesResult = await BillingClient.QueryPurchaseHistoryAsync(skuType); @@ -219,7 +222,7 @@ public override async Task> GetPurchasesHistor /// Purchase token of original subscription /// Proration mode (1 - ImmediateWithTimeProration, 2 - ImmediateAndChargeProratedPrice, 3 - ImmediateWithoutProration, 4 - Deferred) /// Purchase details - public override async Task UpgradePurchasedSubscriptionAsync(string newProductId, string purchaseTokenOfOriginalSubscription,SubscriptionProrationMode prorationMode = SubscriptionProrationMode.ImmediateWithTimeProration) + public override async Task UpgradePurchasedSubscriptionAsync(string newProductId, string purchaseTokenOfOriginalSubscription, SubscriptionProrationMode prorationMode = SubscriptionProrationMode.ImmediateWithTimeProration) { if (BillingClient == null || !IsConnected) { @@ -240,25 +243,25 @@ public override async Task UpgradePurchasedSubscriptionAsy async Task UpgradePurchasedSubscriptionInternalAsync(string newProductId, string purchaseTokenOfOriginalSubscription, SubscriptionProrationMode prorationMode) { - var itemType = BillingClient.SkuType.Subs; + var itemType = ProductType.Subs; if (tcsPurchase?.Task != null && !tcsPurchase.Task.IsCompleted) { return null; } - var skuDetailsParams = SkuDetailsParams.NewBuilder() - .SetType(itemType) - .SetSkusList(new List { newProductId }) - .Build(); + var productList = QueryProductDetailsParams.Product.NewBuilder() + .SetProductType(itemType) + .SetProductId(newProductId) + .Build(); + + var skuDetailsParams = QueryProductDetailsParams.NewBuilder().SetProductList(new[] { productList }).Build(); - var skuDetailsResult = await BillingClient.QuerySkuDetailsAsync(skuDetailsParams); - ParseBillingResult(skuDetailsResult?.Result); + var skuDetailsResult = await BillingClient.QueryProductDetailsAsync(skuDetailsParams); - var skuDetails = skuDetailsResult?.SkuDetails.FirstOrDefault(); + ParseBillingResult(skuDetailsResult.Result); - if (skuDetails == null) - throw new ArgumentException($"{newProductId} does not exist"); + var skuDetails = skuDetailsResult.ProductDetails.FirstOrDefault() ?? throw new ArgumentException($"{newProductId} does not exist"); //1 - BillingFlowParams.ProrationMode.ImmediateWithTimeProration //2 - BillingFlowParams.ProrationMode.ImmediateAndChargeProratedPrice @@ -267,16 +270,21 @@ async Task UpgradePurchasedSubscriptionInternalAsync(strin //5 - BillingFlowParams.ProrationMode.ImmediateAndChargeFullPrice var updateParams = BillingFlowParams.SubscriptionUpdateParams.NewBuilder() - .SetOldSkuPurchaseToken(purchaseTokenOfOriginalSubscription) - .SetReplaceSkusProrationMode((int)prorationMode) + .SetOldPurchaseToken(purchaseTokenOfOriginalSubscription) + .SetReplaceProrationMode((int)prorationMode) + .Build(); + + var prodDetailsParams = BillingFlowParams.ProductDetailsParams.NewBuilder() + .SetProductDetails(skuDetails) + .SetOfferToken(skuDetails.GetSubscriptionOfferDetails()?.FirstOrDefault()?.OfferToken) .Build(); var flowParams = BillingFlowParams.NewBuilder() - .SetSkuDetails(skuDetails) + .SetProductDetailsParamsList(new[] { prodDetailsParams }) .SetSubscriptionUpdateParams(updateParams) .Build(); - tcsPurchase = new TaskCompletionSource<(BillingResult billingResult, IList purchases)>(); + tcsPurchase = new TaskCompletionSource<(BillingResult billingResult, IList purchases)>(); var responseCode = BillingClient.LaunchBillingFlow(Activity, flowParams); ParseBillingResult(responseCode); @@ -285,12 +293,12 @@ async Task UpgradePurchasedSubscriptionInternalAsync(strin ParseBillingResult(result.billingResult); //we are only buying 1 thing. - var androidPurchase = result.purchases?.FirstOrDefault(p => p.Skus.Contains(newProductId)); + var androidPurchase = result.purchases?.FirstOrDefault(p => p.Products.Contains(newProductId)); //for some reason the data didn't come back if (androidPurchase == null) { - var purchases = await GetPurchasesAsync(itemType == BillingClient.SkuType.Inapp ? ItemType.InAppPurchase : ItemType.Subscription); + var purchases = await GetPurchasesAsync(itemType == ProductType.Inapp ? ItemType.InAppPurchase : ItemType.Subscription); return purchases.FirstOrDefault(p => p.ProductId == newProductId); } @@ -305,7 +313,7 @@ async Task UpgradePurchasedSubscriptionInternalAsync(strin /// Specifies an optional obfuscated string that is uniquely associated with the user's account in your app. /// Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. /// - public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null) + public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null) { if (BillingClient == null || !IsConnected) { @@ -318,63 +326,81 @@ public async override Task PurchaseAsync(string productId, { return null; } + + if(!string.IsNullOrWhiteSpace(obfuscatedProfileId) && string.IsNullOrWhiteSpace(obfuscatedAccountId)) + throw new ArgumentNullException("You must set an account id if you are setting a profile id"); switch (itemType) { case ItemType.InAppPurchase: case ItemType.InAppPurchaseConsumable: - return await PurchaseAsync(productId, BillingClient.SkuType.Inapp, obfuscatedAccountId, obfuscatedProfileId); + return await PurchaseAsync(productId, ProductType.Inapp, obfuscatedAccountId, obfuscatedProfileId); case ItemType.Subscription: - var result = BillingClient.IsFeatureSupported(BillingClient.FeatureType.Subscriptions); + var result = BillingClient.IsFeatureSupported(FeatureType.Subscriptions); ParseBillingResult(result); - return await PurchaseAsync(productId, BillingClient.SkuType.Subs, obfuscatedAccountId, obfuscatedProfileId); + return await PurchaseAsync(productId, ProductType.Subs, obfuscatedAccountId, obfuscatedProfileId, subOfferToken); } return null; } - async Task PurchaseAsync(string productSku, string itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null) - { + async Task PurchaseAsync(string productSku, string itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null) + { - var skuDetailsParams = SkuDetailsParams.NewBuilder() - .SetType(itemType) - .SetSkusList(new List { productSku }) + var productList = QueryProductDetailsParams.Product.NewBuilder() + .SetProductType(itemType) + .SetProductId(productSku) .Build(); - var skuDetailsResult = await BillingClient.QuerySkuDetailsAsync(skuDetailsParams); - ParseBillingResult(skuDetailsResult?.Result); + var skuDetailsParams = QueryProductDetailsParams.NewBuilder().SetProductList(new[] { productList }); - var skuDetails = skuDetailsResult.SkuDetails.FirstOrDefault(); + var skuDetailsResult = await BillingClient.QueryProductDetailsAsync(skuDetailsParams.Build()); + + ParseBillingResult(skuDetailsResult.Result); + + var skuDetails = skuDetailsResult.ProductDetails.FirstOrDefault() ?? throw new ArgumentException($"{productSku} does not exist"); + var productDetailsParamsList = itemType == ProductType.Subs ? + BillingFlowParams.ProductDetailsParams.NewBuilder() + .SetProductDetails(skuDetails) + .SetOfferToken(subOfferToken ?? skuDetails.GetSubscriptionOfferDetails()?.FirstOrDefault()?.OfferToken ?? string.Empty) + .Build() + : BillingFlowParams.ProductDetailsParams.NewBuilder() + .SetProductDetails(skuDetails) + .Build(); + + var billingFlowParams = BillingFlowParams.NewBuilder() + .SetProductDetailsParamsList(new[] { productDetailsParamsList }); - if (skuDetails == null) - throw new ArgumentException($"{productSku} does not exist"); - var flowParamsBuilder = BillingFlowParams.NewBuilder() - .SetSkuDetails(skuDetails); if (!string.IsNullOrWhiteSpace(obfuscatedAccountId)) - flowParamsBuilder.SetObfuscatedAccountId(obfuscatedAccountId); + billingFlowParams.SetObfuscatedAccountId(obfuscatedAccountId); if (!string.IsNullOrWhiteSpace(obfuscatedProfileId)) - flowParamsBuilder.SetObfuscatedProfileId(obfuscatedProfileId); + billingFlowParams.SetObfuscatedProfileId(obfuscatedProfileId); + + var flowParams = billingFlowParams.Build(); + - var flowParams = flowParamsBuilder.Build(); tcsPurchase = new TaskCompletionSource<(BillingResult billingResult, IList purchases)>(); + + var responseCode = BillingClient.LaunchBillingFlow(Activity, flowParams); - ParseBillingResult(responseCode); + + ParseBillingResult(responseCode); var result = await tcsPurchase.Task; ParseBillingResult(result.billingResult); //we are only buying 1 thing. - var androidPurchase = result.purchases?.FirstOrDefault(p => p.Skus.Contains(productSku)); + var androidPurchase = result.purchases?.FirstOrDefault(p => p.Products.Contains(productSku)); //for some reason the data didn't come back if (androidPurchase == null) { - var purchases = await GetPurchasesAsync(itemType == BillingClient.SkuType.Inapp ? ItemType.InAppPurchase : ItemType.Subscription); + var purchases = await GetPurchasesAsync(itemType == ProductType.Inapp ? ItemType.InAppPurchase : ItemType.Subscription); return purchases.FirstOrDefault(p => p.ProductId == productSku); } @@ -403,10 +429,11 @@ public async override Task> FinalizePurch return items; } - //inapp:{Context.PackageName}:{productSku} + /// /// Consume a purchase with a purchase token. + /// in app:{Context.PackageName}:{productSku} /// /// Id or Sku of product /// Original Purchase Token @@ -418,7 +445,7 @@ public override async Task ConsumePurchaseAsync(string productId, string t throw new InAppBillingPurchaseException(PurchaseError.ServiceUnavailable, "You are not connected to the Google Play App store."); } - + var consumeParams = ConsumeParams.NewBuilder() .SetPurchaseToken(transactionIdentifier) .Build(); @@ -426,14 +453,17 @@ public override async Task ConsumePurchaseAsync(string productId, string t var result = await BillingClient.ConsumeAsync(consumeParams); - return ParseBillingResult(result.BillingResult); + return ParseBillingResult(result.BillingResult); } - bool ParseBillingResult(BillingResult result) + static bool ParseBillingResult(BillingResult result, bool ignoreInvalidProducts = false) { - if(result == null) + if (result == null) throw new InAppBillingPurchaseException(PurchaseError.GeneralError); + if ((int)result.ResponseCode == Android.BillingClient.Api.BillingClient.BillingResponseCode.NetworkError) + throw new InAppBillingPurchaseException(PurchaseError.ServiceTimeout);//Network connection is down + return result.ResponseCode switch { BillingResponseCode.Ok => true, @@ -447,10 +477,10 @@ bool ParseBillingResult(BillingResult result) BillingResponseCode.Error => throw new InAppBillingPurchaseException(PurchaseError.GeneralError),//Generic Error BillingResponseCode.FeatureNotSupported => throw new InAppBillingPurchaseException(PurchaseError.FeatureNotSupported), BillingResponseCode.ItemAlreadyOwned => throw new InAppBillingPurchaseException(PurchaseError.AlreadyOwned), - BillingResponseCode.ItemUnavailable => throw new InAppBillingPurchaseException(PurchaseError.ItemUnavailable), + BillingResponseCode.ItemUnavailable => ignoreInvalidProducts ? false : throw new InAppBillingPurchaseException(PurchaseError.ItemUnavailable), _ => false, }; - } + } } } - + diff --git a/src/Plugin.InAppBilling/InAppBilling.apple.cs b/src/Plugin.InAppBilling/InAppBilling.apple.cs index 213c55d..4465904 100644 --- a/src/Plugin.InAppBilling/InAppBilling.apple.cs +++ b/src/Plugin.InAppBilling/InAppBilling.apple.cs @@ -211,7 +211,7 @@ Task> GetProductAsync(string[] productId) { var productIdentifiers = NSSet.MakeNSObjectSet(productId.Select(i => new NSString(i)).ToArray()); - var productRequestDelegate = new ProductRequestDelegate(); + var productRequestDelegate = new ProductRequestDelegate(IgnoreInvalidProducts); //set up product request for in-app purchase var productsRequest = new SKProductsRequest(productIdentifiers) @@ -224,7 +224,7 @@ Task> GetProductAsync(string[] productId) } /// - /// Get app purchaes + /// Get app purchase /// /// /// @@ -315,7 +315,7 @@ static SKPaymentTransaction FindOriginalTransaction(SKPaymentTransaction transac /// Specifies an optional obfuscated string that is uniquely associated with the user's account in your app. /// Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. /// - public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null) + public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null) { Init(); var p = await PurchaseAsync(productId, itemType, obfuscatedAccountId); @@ -325,7 +325,7 @@ public async override Task PurchaseAsync(string productId, var purchase = new InAppBillingPurchase { - TransactionDateUtc = reference.AddSeconds(p.TransactionDate.SecondsSinceReferenceDate), + TransactionDateUtc = reference.AddSeconds(p.TransactionDate?.SecondsSinceReferenceDate ?? 0), Id = p.TransactionIdentifier, OriginalTransactionIdentifier = p.OriginalTransaction?.TransactionIdentifier, TransactionIdentifier = p.TransactionIdentifier, @@ -626,6 +626,12 @@ public override void Dispose(bool disposing) [Preserve(AllMembers = true)] class ProductRequestDelegate : NSObject, ISKProductsRequestDelegate, ISKRequestDelegate { + bool ignoreInvalidProducts; + public ProductRequestDelegate(bool ignoreInvalidProducts) + { + this.ignoreInvalidProducts = ignoreInvalidProducts; + } + readonly TaskCompletionSource> tcsResponse = new(); public Task> WaitForResponse() => @@ -639,12 +645,15 @@ class ProductRequestDelegate : NSObject, ISKProductsRequestDelegate, ISKRequestD public void ReceivedResponse(SKProductsRequest request, SKProductsResponse response) { - var invalidProduct = response.InvalidProducts; - if (invalidProduct?.Any() ?? false) - { - tcsResponse.TrySetException(new InAppBillingPurchaseException(PurchaseError.InvalidProduct, $"Invalid Product: {invalidProduct.First()}")); - return; - } + if (!ignoreInvalidProducts) + { + var invalidProducts = response.InvalidProducts; + if (invalidProducts?.Any() ?? false) + { + tcsResponse.TrySetException(new InAppBillingPurchaseException(PurchaseError.InvalidProduct, "Invalid products found when querying product list", invalidProducts)); + return; + } + } var product = response.Products; if (product != null) diff --git a/src/Plugin.InAppBilling/InAppBilling.uwp.cs b/src/Plugin.InAppBilling/InAppBilling.uwp.cs index d44992f..33d0110 100644 --- a/src/Plugin.InAppBilling/InAppBilling.uwp.cs +++ b/src/Plugin.InAppBilling/InAppBilling.uwp.cs @@ -94,7 +94,7 @@ public async override Task> GetPurchasesAsync( /// Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. /// /// If an error occurs during processing - public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null) + public async override Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null) { // Get purchase result from store or simulator var purchaseResult = await CurrentAppMock.RequestProductPurchaseAsync(InTestingMode, productId); diff --git a/src/Plugin.InAppBilling/Plugin.InAppBilling.csproj b/src/Plugin.InAppBilling/Plugin.InAppBilling.csproj index 7750022..010af4f 100644 --- a/src/Plugin.InAppBilling/Plugin.InAppBilling.csproj +++ b/src/Plugin.InAppBilling/Plugin.InAppBilling.csproj @@ -1,8 +1,9 @@  - netstandard2.0;MonoAndroid10.0;Xamarin.iOS10;Xamarin.TVOS10;Xamarin.Mac20;net6.0-android;net6.0-ios;net6.0-maccatalyst;net6.0-tvos;net6.0-macos + + netstandard2.0;MonoAndroid12.0;Xamarin.iOS10;Xamarin.TVOS10;Xamarin.Mac20;net6.0-android;net6.0-ios;net6.0-maccatalyst;net6.0-tvos;net6.0-macos $(TargetFrameworks);uap10.0.19041;net6.0-windows10.0.19041; - 9.0 + 10.0 Plugin.InAppBilling Plugin.InAppBilling $(AssemblyName) ($(TargetFramework)) @@ -97,14 +98,14 @@ - - + + - - + + diff --git a/src/Plugin.InAppBilling/Shared/BaseInAppBilling.shared.cs b/src/Plugin.InAppBilling/Shared/BaseInAppBilling.shared.cs index 929bdc5..9f06d30 100644 --- a/src/Plugin.InAppBilling/Shared/BaseInAppBilling.shared.cs +++ b/src/Plugin.InAppBilling/Shared/BaseInAppBilling.shared.cs @@ -31,6 +31,12 @@ public abstract class BaseInAppBilling : IInAppBilling, IDisposable /// public virtual bool IsConnected { get; set; } = true; + + /// + /// If you want to ignore invalid products when getting details + /// + public virtual bool IgnoreInvalidProducts { get; set; } = false; + /// /// Gets or sets if in testing mode /// @@ -84,7 +90,7 @@ public abstract class BaseInAppBilling : IInAppBilling, IDisposable /// Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. /// Purchase details /// If an error occurs during processing - public abstract Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null); + public abstract Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null); /// /// (Android specific) Upgrade/Downgrade a previously purchased subscription diff --git a/src/Plugin.InAppBilling/Shared/IInAppBilling.shared.cs b/src/Plugin.InAppBilling/Shared/IInAppBilling.shared.cs index 8685512..b4fe744 100644 --- a/src/Plugin.InAppBilling/Shared/IInAppBilling.shared.cs +++ b/src/Plugin.InAppBilling/Shared/IInAppBilling.shared.cs @@ -20,6 +20,12 @@ public interface IInAppBilling : IDisposable /// bool InTestingMode { get; set; } + + /// + /// If you want to ignore invalid products when getting details + /// + bool IgnoreInvalidProducts { get; set; } + /// /// Represenation of the storefront if available /// @@ -85,7 +91,7 @@ public interface IInAppBilling : IDisposable /// Android: Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. /// Purchase details /// If an error occurs during processing - Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null); + Task PurchaseAsync(string productId, ItemType itemType, string obfuscatedAccountId = null, string obfuscatedProfileId = null, string subOfferToken = null); /// /// (Android specific) Upgrade/Downgrade a previously purchased subscription diff --git a/src/Plugin.InAppBilling/Shared/InAppBillingExceptions.shared.cs b/src/Plugin.InAppBilling/Shared/InAppBillingExceptions.shared.cs index b9ea9b8..39ce8c3 100644 --- a/src/Plugin.InAppBilling/Shared/InAppBillingExceptions.shared.cs +++ b/src/Plugin.InAppBilling/Shared/InAppBillingExceptions.shared.cs @@ -82,6 +82,9 @@ public class InAppBillingPurchaseException : Exception /// Type of error /// public PurchaseError PurchaseError { get; } + + public string[] Invalid { get; } + /// /// /// @@ -99,5 +102,16 @@ public class InAppBillingPurchaseException : Exception /// /// public InAppBillingPurchaseException(PurchaseError error, string message) : base(message) => PurchaseError = error; + + + /// + /// + /// + /// + public InAppBillingPurchaseException(PurchaseError error, string message, string[] invalid) : base(message) + { + PurchaseError = error; + Invalid = invalid; + } } } diff --git a/src/Plugin.InAppBilling/Shared/InAppBillingProduct.shared.cs b/src/Plugin.InAppBilling/Shared/InAppBillingProduct.shared.cs index a4f24a8..a8f8b59 100644 --- a/src/Plugin.InAppBilling/Shared/InAppBillingProduct.shared.cs +++ b/src/Plugin.InAppBilling/Shared/InAppBillingProduct.shared.cs @@ -26,13 +26,13 @@ public class InAppBillingProductAppleExtras public bool IsFamilyShareable { get; set; } /// - /// iOS 11.2: gets information about product discunt + /// iOS 11.2: gets information about product discount /// public InAppBillingProductDiscount IntroductoryOffer { get; set; } = null; /// - /// iOS 12.2: gets information about product discunt + /// iOS 12.2: gets information about product discount /// public List Discounts { get; set; } = null; } @@ -87,51 +87,56 @@ public class InAppBillingProductWindowsExtras public class InAppBillingProductAndroidExtras { /// - /// Subscription period, specified in ISO 8601 format. - /// - public string SubscriptionPeriod { get; set; } - - /// - /// Trial period, specified in ISO 8601 format. - /// - public string FreeTrialPeriod { get; set; } - - /// - /// Icon of the product if present - /// - public string IconUrl { get; set; } - - /// - /// Gets or sets the localized introductory price. - /// - /// The localized introductory price. - public string LocalizedIntroductoryPrice { get; set; } - - /// - /// Number of subscription billing periods for which the user will be given the introductory price, such as 3 - /// - public int IntroductoryPriceCycles { get; set; } - - /// - /// Billing period of the introductory price, specified in ISO 8601 format - /// - public string IntroductoryPricePeriod { get; set; } - - /// - /// Introductory price of the product in micro-units - /// - /// The introductory price. - public Int64 MicrosIntroductoryPrice { get; set; } - - /// - /// Formatted original price of the item, including its currency sign. - /// - public string OriginalPrice { get; set; } - - /// - /// Original price in micro-units, where 1,000,000, micro-units equal one unit of the currency + /// The period details for products that are subscriptions. /// - public long MicrosOriginalPriceAmount { get; set; } + public List SubscriptionOfferDetails { get; set; } + + ///// + ///// Subscription period, specified in ISO 8601 format. + ///// + //public string SubscriptionPeriod { get; set; } + + ///// + ///// Trial period, specified in ISO 8601 format. + ///// + //public string FreeTrialPeriod { get; set; } + + ///// + ///// Icon of the product if present + ///// + //public string IconUrl { get; set; } + + ///// + ///// Gets or sets the localized introductory price. + ///// + ///// The localized introductory price. + //public string LocalizedIntroductoryPrice { get; set; } + + ///// + ///// Number of subscription billing periods for which the user will be given the introductory price, such as 3 + ///// + //public int IntroductoryPriceCycles { get; set; } + + ///// + ///// Billing period of the introductory price, specified in ISO 8601 format + ///// + //public string IntroductoryPricePeriod { get; set; } + + ///// + ///// Introductory price of the product in micro-units + ///// + ///// The introductory price. + //public Int64 MicrosIntroductoryPrice { get; set; } + + ///// + ///// Formatted original price of the item, including its currency sign. + ///// + //public string OriginalPrice { get; set; } + + ///// + ///// Original price in micro-units, where 1,000,000, micro-units equal one unit of the currency + ///// + //public long MicrosOriginalPriceAmount { get; set; } } diff --git a/src/Plugin.InAppBilling/Shared/InAppBillingProductDiscount.shared.cs b/src/Plugin.InAppBilling/Shared/InAppBillingProductDiscount.shared.cs index 36d43f5..32212d4 100644 --- a/src/Plugin.InAppBilling/Shared/InAppBillingProductDiscount.shared.cs +++ b/src/Plugin.InAppBilling/Shared/InAppBillingProductDiscount.shared.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace Plugin.InAppBilling { @@ -128,4 +129,23 @@ public enum ProductDiscountType /// Unknown } + + public class SubscriptionOfferDetail + { + public string BasePlanId { get; set; } + public string OfferId { get; set; } + public List OfferTags { get; set; } + public string OfferToken { get; set; } + public List PricingPhases { get; set; } + } + + public class PricingPhase + { + public int BillingCycleCount { get; set; } + public string BillingPeriod { get; set; } + public string FormattedPrice { get; set; } + public long PriceAmountMicros { get; set; } + public string PriceCurrencyCode { get; set; } + public int RecurrenceMode { get; set; } + } }