From 00dd993fe28fd8af29f637a109a30d09731002e0 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Wed, 5 Apr 2023 16:35:32 +0200 Subject: [PATCH 01/17] Remove v4 code Implement v5 calls --- .../inapppurchase/InAppPurchasePlugin.java | 7 +- .../inapppurchase/MethodCallHandlerImpl.java | 270 ++++++++---------- .../plugins/inapppurchase/Translator.java | 128 ++++++--- .../inapppurchase/MethodCallHandlerTest.java | 39 ++- .../plugins/inapppurchase/TranslatorTest.java | 3 - .../example/lib/main.dart | 8 +- .../lib/billing_client_wrappers.dart | 5 +- .../billing_client_wrapper.dart | 162 ++++++----- .../billing_client_wrapper.g.dart | 7 +- .../billing_response_wrapper.dart | 62 ++++ .../billing_response_wrapper.g.dart | 14 + ...e_time_purchase_offer_details_wrapper.dart | 74 +++++ ...time_purchase_offer_details_wrapper.g.dart | 15 + .../product_details_wrapper.dart | 190 ++++++++++++ .../product_details_wrapper.g.dart | 49 ++++ .../purchase_wrapper.dart | 57 ++-- .../purchase_wrapper.g.dart | 14 +- .../sku_details_wrapper.dart | 268 ----------------- .../sku_details_wrapper.g.dart | 47 --- .../subscription_offer_details_wrapper.dart | 158 ++++++++++ .../subscription_offer_details_wrapper.g.dart | 36 +++ .../src/in_app_purchase_android_platform.dart | 114 +++++--- ...pp_purchase_android_platform_addition.dart | 20 +- .../types/google_play_product_details.dart | 119 +++++++- .../types/google_play_purchase_details.dart | 2 +- .../lib/src/types/product.dart | 16 ++ .../lib/src/types/types.dart | 1 + .../in_app_purchase_android/pubspec.yaml | 2 +- .../billing_client_wrapper_test.dart | 28 +- .../sku_details_wrapper_test.dart | 4 +- ...in_app_purchase_android_platform_test.dart | 18 +- 31 files changed, 1188 insertions(+), 749 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.g.dart delete mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart delete mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/types/product.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index 6f4e4bbfd8ee..d2189a835c17 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -32,16 +32,15 @@ static final class MethodNames { "BillingClient#startConnection(BillingClientStateListener)"; static final String END_CONNECTION = "BillingClient#endConnection()"; static final String ON_DISCONNECT = "BillingClientStateListener#onBillingServiceDisconnected()"; - static final String QUERY_SKU_DETAILS = - "BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)"; + static final String QUERY_PRODUCT_DETAILS = + "BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)"; static final String LAUNCH_BILLING_FLOW = "BillingClient#launchBillingFlow(Activity, BillingFlowParams)"; static final String ON_PURCHASES_UPDATED = "PurchasesUpdatedListener#onPurchasesUpdated(int, List)"; static final String QUERY_PURCHASES = "BillingClient#queryPurchases(String)"; static final String QUERY_PURCHASES_ASYNC = "BillingClient#queryPurchasesAsync(String)"; - static final String QUERY_PURCHASE_HISTORY_ASYNC = - "BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)"; + static final String QUERY_PURCHASE_HISTORY_ASYNC = "BillingClient#queryPurchaseHistoryAsync(String)"; static final String CONSUME_PURCHASE_ASYNC = "BillingClient#consumeAsync(String, ConsumeResponseListener)"; static final String ACKNOWLEDGE_PURCHASE = diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index b6a27561d9d5..5444e01e7ba3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -4,9 +4,10 @@ package io.flutter.plugins.inapppurchase; +import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; +import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; -import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; import android.app.Activity; import android.app.Application; @@ -24,24 +25,32 @@ import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.ProductDetails; +import com.android.billingclient.api.ProductDetailsResponseListener; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.PurchasesResponseListener; +import com.android.billingclient.api.QueryProductDetailsParams; +import com.android.billingclient.api.QueryProductDetailsParams.Product; import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchasesParams; + import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; + +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** Handles method channel for the plugin. */ class MethodCallHandlerImpl implements MethodChannel.MethodCallHandler, Application.ActivityLifecycleCallbacks { private static final String TAG = "InAppPurchasePlugin"; - private static final String LOAD_SKU_DOC_URL = + private static final String LOAD_PRODUCT_DOC_URL = "https://github.com/flutter/packages/blob/main/packages/in_app_purchase/in_app_purchase/README.md#loading-products-for-sale"; @Nullable private BillingClient billingClient; @@ -51,9 +60,7 @@ class MethodCallHandlerImpl private final Context applicationContext; private final MethodChannel methodChannel; - // TODO(stuartmorgan): Migrate this code. See TODO on querySkuDetailsAsync. - @SuppressWarnings("deprecation") - private HashMap cachedSkus = new HashMap<>(); + private final HashMap cachedProducts = new HashMap<>(); /** Constructs the MethodCallHandlerImpl */ MethodCallHandlerImpl( @@ -117,31 +124,29 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { case InAppPurchasePlugin.MethodNames.END_CONNECTION: endConnection(result); break; - case InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS: - List skusList = call.argument("skusList"); - querySkuDetailsAsync((String) call.argument("skuType"), skusList, result); + case InAppPurchasePlugin.MethodNames.QUERY_PRODUCT_DETAILS: + List productIds = call.argument("productIds"); + List productTypes = call.argument("productTypes"); + queryProductDetailsAsync(productIds, productTypes, result); break; case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW: launchBillingFlow( - (String) call.argument("sku"), + (String) call.argument("product"), + (String) call.argument("offerToken"), (String) call.argument("accountId"), (String) call.argument("obfuscatedProfileId"), - (String) call.argument("oldSku"), + (String) call.argument("oldProduct"), (String) call.argument("purchaseToken"), call.hasArgument("prorationMode") ? (int) call.argument("prorationMode") : ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, result); break; - case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES: // Legacy method name. - queryPurchasesAsync((String) call.argument("skuType"), result); - break; case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES_ASYNC: - queryPurchasesAsync((String) call.argument("skuType"), result); + queryPurchasesAsync((String) call.argument("productType"), result); break; case InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC: - Log.e("flutter", (String) call.argument("skuType")); - queryPurchaseHistoryAsync((String) call.argument("skuType"), result); + queryPurchaseHistoryAsync((String) call.argument("productType"), result); break; case InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC: consumeAsync((String) call.argument("purchaseToken"), result); @@ -152,9 +157,6 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { case InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED: isFeatureSupported((String) call.argument("feature"), result); break; - case InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW: - launchPriceChangeConfirmationFlow((String) call.argument("sku"), result); - break; case InAppPurchasePlugin.MethodNames.GET_CONNECTION_STATE: getConnectionState(result); break; @@ -183,95 +185,119 @@ private void isReady(MethodChannel.Result result) { result.success(billingClient.isReady()); } - // TODO(stuartmorgan): Migrate to new subscriptions API. See: - // - https://developer.android.com/google/play/billing/migrate-gpblv5 - // - https://github.com/flutter/flutter/issues/114265 - // - https://github.com/flutter/flutter/issues/107370 - @SuppressWarnings("deprecation") - private void querySkuDetailsAsync( - final String skuType, final List skusList, final MethodChannel.Result result) { + private void queryProductDetailsAsync( + final List productIds, + final List productTypes, + final MethodChannel.Result result + ) { + assert(productIds.size() == productTypes.size()); + if (billingClientError(result)) { return; } - com.android.billingclient.api.SkuDetailsParams params = - com.android.billingclient.api.SkuDetailsParams.newBuilder() - .setType(skuType) - .setSkusList(skusList) - .build(); - billingClient.querySkuDetailsAsync( - params, - new com.android.billingclient.api.SkuDetailsResponseListener() { - @Override - public void onSkuDetailsResponse( - BillingResult billingResult, - List skuDetailsList) { - updateCachedSkus(skuDetailsList); - final Map skuDetailsResponse = new HashMap<>(); - skuDetailsResponse.put("billingResult", Translator.fromBillingResult(billingResult)); - skuDetailsResponse.put("skuDetailsList", fromSkuDetailsList(skuDetailsList)); - result.success(skuDetailsResponse); - } - }); + List productList = new ArrayList<>(); + for (int i = 0; i < productIds.size(); i++) { + productList.add(Product.newBuilder() + .setProductId(productIds.get(i)) + .setProductType(productTypes.get(i)) + .build()); + } + + QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder().setProductList(productList).build(); + billingClient.queryProductDetailsAsync(params, new ProductDetailsResponseListener() { + @Override + public void onProductDetailsResponse(@NonNull BillingResult billingResult, @NonNull List productDetailsList) { + updateCachedProducts(productDetailsList); + final Map productDetailsResponse = new HashMap<>(); + productDetailsResponse.put("billingResult", fromBillingResult(billingResult)); + productDetailsResponse.put("productDetailsList", fromProductDetailsList(productDetailsList)); + result.success(productDetailsResponse); + } + }); } private void launchBillingFlow( - String sku, - @Nullable String accountId, - @Nullable String obfuscatedProfileId, - @Nullable String oldSku, - @Nullable String purchaseToken, - int prorationMode, - MethodChannel.Result result) { + String product, + @Nullable String offerToken, + @Nullable String accountId, + @Nullable String obfuscatedProfileId, + @Nullable String oldProduct, + @Nullable String purchaseToken, + int prorationMode, + MethodChannel.Result result) { if (billingClientError(result)) { return; } - // TODO(stuartmorgan): Migrate this code. See TODO on querySkuDetailsAsync. - @SuppressWarnings("deprecation") - com.android.billingclient.api.SkuDetails skuDetails = cachedSkus.get(sku); - if (skuDetails == null) { + + com.android.billingclient.api.ProductDetails productDetails = cachedProducts.get(product); + if (productDetails == null) { result.error( - "NOT_FOUND", - "Details for sku " - + sku - + " are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: " - + LOAD_SKU_DOC_URL, - null); + "NOT_FOUND", + String.format( + "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", + product, LOAD_PRODUCT_DOC_URL), + null); return; } - if (oldSku == null - && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { + @Nullable List subscriptionOfferDetails = productDetails.getSubscriptionOfferDetails(); + if (subscriptionOfferDetails != null) { + boolean isValidOfferToken = false; + for (ProductDetails.SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) { + if (Objects.equals(offerDetails.getOfferToken(), offerToken)) { + isValidOfferToken = true; + break; + } + } + if (!isValidOfferToken) { + result.error( + "INVALID_OFFER_TOKEN", + String.format( + "Offer token %s for product %s is not valid. Make sure to only pass offer tokens that belong to the product. To obtain offer tokens for a product, fetch the products. An example of how to fetch the products could be found here: %s", + offerToken, product, LOAD_PRODUCT_DOC_URL), + null); + return; + } + } + + if (oldProduct == null + && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { result.error( - "IN_APP_PURCHASE_REQUIRE_OLD_SKU", - "launchBillingFlow failed because oldSku is null. You must provide a valid oldSku in order to use a proration mode.", - null); + "IN_APP_PURCHASE_REQUIRE_OLD_PRODUCT", + "launchBillingFlow failed because oldProduct is null. You must provide a valid oldProduct in order to use a proration mode.", + null); return; - } else if (oldSku != null && !cachedSkus.containsKey(oldSku)) { + } else if (oldProduct != null && !cachedProducts.containsKey(oldProduct)) { result.error( - "IN_APP_PURCHASE_INVALID_OLD_SKU", - "Details for sku " - + oldSku - + " are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: " - + LOAD_SKU_DOC_URL, - null); + "IN_APP_PURCHASE_INVALID_OLD_PRODUCT", + String.format( + "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", + oldProduct, LOAD_PRODUCT_DOC_URL), + null); return; } if (activity == null) { result.error( - "ACTIVITY_UNAVAILABLE", - "Details for sku " - + sku - + " are not available. This method must be run with the app in foreground.", - null); + "ACTIVITY_UNAVAILABLE", + String.format("Details for product %s are not available. This method must be run with the app in foreground.", + product), + null); return; } - // TODO(stuartmorgan): Migrate this code. See TODO on querySkuDetailsAsync. - @SuppressWarnings("deprecation") + BillingFlowParams.ProductDetailsParams.Builder productDetailsParamsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder(); + productDetailsParamsBuilder.setProductDetails(productDetails); + if (offerToken != null) { + productDetailsParamsBuilder.setOfferToken(offerToken); + } + + List productDetailsParamsList = new ArrayList<>(); + productDetailsParamsList.add(productDetailsParamsBuilder.build()); + BillingFlowParams.Builder paramsBuilder = - BillingFlowParams.newBuilder().setSkuDetails(skuDetails); + BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParamsList); if (accountId != null && !accountId.isEmpty()) { paramsBuilder.setObfuscatedAccountId(accountId); } @@ -279,8 +305,8 @@ private void launchBillingFlow( paramsBuilder.setObfuscatedProfileId(obfuscatedProfileId); } BillingFlowParams.SubscriptionUpdateParams.Builder subscriptionUpdateParamsBuilder = - BillingFlowParams.SubscriptionUpdateParams.newBuilder(); - if (oldSku != null && !oldSku.isEmpty() && purchaseToken != null) { + BillingFlowParams.SubscriptionUpdateParams.newBuilder(); + if (oldProduct != null && !oldProduct.isEmpty() && purchaseToken != null) { subscriptionUpdateParamsBuilder.setOldPurchaseToken(purchaseToken); // The proration mode value has to match one of the following declared in // https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode @@ -288,8 +314,8 @@ private void launchBillingFlow( paramsBuilder.setSubscriptionUpdateParams(subscriptionUpdateParamsBuilder.build()); } result.success( - Translator.fromBillingResult( - billingClient.launchBillingFlow(activity, paramsBuilder.build()))); + fromBillingResult( + billingClient.launchBillingFlow(activity, paramsBuilder.build()))); } private void consumeAsync(String purchaseToken, final MethodChannel.Result result) { @@ -301,7 +327,7 @@ private void consumeAsync(String purchaseToken, final MethodChannel.Result resul new ConsumeResponseListener() { @Override public void onConsumeResponse(BillingResult billingResult, String outToken) { - result.success(Translator.fromBillingResult(billingResult)); + result.success(fromBillingResult(billingResult)); } }; ConsumeParams.Builder paramsBuilder = @@ -312,7 +338,7 @@ public void onConsumeResponse(BillingResult billingResult, String outToken) { billingClient.consumeAsync(params, listener); } - private void queryPurchasesAsync(String skuType, MethodChannel.Result result) { + private void queryPurchasesAsync(String productType, MethodChannel.Result result) { if (billingClientError(result)) { return; } @@ -320,7 +346,7 @@ private void queryPurchasesAsync(String skuType, MethodChannel.Result result) { // Like in our connect call, consider the billing client responding a "success" here regardless // of status code. QueryPurchasesParams.Builder paramsBuilder = QueryPurchasesParams.newBuilder(); - paramsBuilder.setProductType(skuType); + paramsBuilder.setProductType(productType); billingClient.queryPurchasesAsync( paramsBuilder.build(), new PurchasesResponseListener() { @@ -331,26 +357,26 @@ public void onQueryPurchasesResponse( // The response code is no longer passed, as part of billing 4.0, so we pass OK here // as success is implied by calling this callback. serialized.put("responseCode", BillingClient.BillingResponseCode.OK); - serialized.put("billingResult", Translator.fromBillingResult(billingResult)); + serialized.put("billingResult", fromBillingResult(billingResult)); serialized.put("purchasesList", fromPurchasesList(purchasesList)); result.success(serialized); } }); } - private void queryPurchaseHistoryAsync(String skuType, final MethodChannel.Result result) { + private void queryPurchaseHistoryAsync(String productType, final MethodChannel.Result result) { if (billingClientError(result)) { return; } billingClient.queryPurchaseHistoryAsync( - QueryPurchaseHistoryParams.newBuilder().setProductType(skuType).build(), + QueryPurchaseHistoryParams.newBuilder().setProductType(productType).build(), new PurchaseHistoryResponseListener() { @Override public void onPurchaseHistoryResponse( BillingResult billingResult, List purchasesList) { final Map serialized = new HashMap<>(); - serialized.put("billingResult", Translator.fromBillingResult(billingResult)); + serialized.put("billingResult", fromBillingResult(billingResult)); serialized.put( "purchaseHistoryRecordList", fromPurchaseHistoryRecordList(purchasesList)); result.success(serialized); @@ -385,7 +411,7 @@ public void onBillingSetupFinished(BillingResult billingResult) { alreadyFinished = true; // Consider the fact that we've finished a success, leave it to the Dart side to // validate the responseCode. - result.success(Translator.fromBillingResult(billingResult)); + result.success(fromBillingResult(billingResult)); } @Override @@ -408,67 +434,21 @@ private void acknowledgePurchase(String purchaseToken, final MethodChannel.Resul new AcknowledgePurchaseResponseListener() { @Override public void onAcknowledgePurchaseResponse(BillingResult billingResult) { - result.success(Translator.fromBillingResult(billingResult)); + result.success(fromBillingResult(billingResult)); } }); } - // TODO(stuartmorgan): Migrate this code. See TODO on querySkuDetailsAsync. - @SuppressWarnings("deprecation") - private void updateCachedSkus( - @Nullable List skuDetailsList) { - if (skuDetailsList == null) { + private void updateCachedProducts(@Nullable List productDetailsList) { + if (productDetailsList == null) { return; } - for (com.android.billingclient.api.SkuDetails skuDetails : skuDetailsList) { - cachedSkus.put(skuDetails.getSku(), skuDetails); + for (ProductDetails productDetails : productDetailsList) { + cachedProducts.put(productDetails.getProductId(), productDetails); } } - // TODO(stuartmorgan): Migrate this code. See TODO on querySkuDetailsAsync. - @SuppressWarnings("deprecation") - private void launchPriceChangeConfirmationFlow(String sku, MethodChannel.Result result) { - if (activity == null) { - result.error( - "ACTIVITY_UNAVAILABLE", - "launchPriceChangeConfirmationFlow is not available. " - + "This method must be run with the app in foreground.", - null); - return; - } - if (billingClientError(result)) { - return; - } - // Note that assert doesn't work on Android (see https://stackoverflow.com/a/6176529/5167831 and https://stackoverflow.com/a/8164195/5167831) - // and that this assert is only added to silence the analyser. The actual null check - // is handled by the `billingClientError()` call. - assert billingClient != null; - - com.android.billingclient.api.SkuDetails skuDetails = cachedSkus.get(sku); - if (skuDetails == null) { - result.error( - "NOT_FOUND", - "Details for sku " - + sku - + " are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: " - + LOAD_SKU_DOC_URL, - null); - return; - } - - com.android.billingclient.api.PriceChangeFlowParams params = - new com.android.billingclient.api.PriceChangeFlowParams.Builder() - .setSkuDetails(skuDetails) - .build(); - billingClient.launchPriceChangeConfirmationFlow( - activity, - params, - billingResult -> { - result.success(Translator.fromBillingResult(billingResult)); - }); - } - private boolean billingClientError(MethodChannel.Result result) { if (billingClient != null) { return false; diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 273a28474e92..e42bdce80fe7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -4,9 +4,11 @@ package io.flutter.plugins.inapppurchase; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.AccountIdentifiers; import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; import java.util.ArrayList; @@ -18,55 +20,119 @@ /** Handles serialization of {@link com.android.billingclient.api.BillingClient} related objects. */ /*package*/ class Translator { - // TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. - @SuppressWarnings("deprecation") - static HashMap fromSkuDetail(com.android.billingclient.api.SkuDetails detail) { + static HashMap fromProductDetail(ProductDetails detail) { HashMap info = new HashMap<>(); info.put("title", detail.getTitle()); info.put("description", detail.getDescription()); - info.put("freeTrialPeriod", detail.getFreeTrialPeriod()); - info.put("introductoryPrice", detail.getIntroductoryPrice()); - info.put("introductoryPriceAmountMicros", detail.getIntroductoryPriceAmountMicros()); - info.put("introductoryPriceCycles", detail.getIntroductoryPriceCycles()); - info.put("introductoryPricePeriod", detail.getIntroductoryPricePeriod()); - info.put("price", detail.getPrice()); - info.put("priceAmountMicros", detail.getPriceAmountMicros()); - info.put("priceCurrencyCode", detail.getPriceCurrencyCode()); - info.put("priceCurrencySymbol", currencySymbolFromCode(detail.getPriceCurrencyCode())); - info.put("sku", detail.getSku()); - info.put("type", detail.getType()); - info.put("subscriptionPeriod", detail.getSubscriptionPeriod()); - info.put("originalPrice", detail.getOriginalPrice()); - info.put("originalPriceAmountMicros", detail.getOriginalPriceAmountMicros()); + info.put("productId", detail.getProductId()); + info.put("productType", detail.getProductType()); + info.put("name", detail.getName()); + + @Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails = detail.getOneTimePurchaseOfferDetails(); + if (oneTimePurchaseOfferDetails != null) { + info.put("oneTimePurchaseOfferDetails", fromOneTimePurchaseOfferDetails(oneTimePurchaseOfferDetails)); + } + + @Nullable List subscriptionOfferDetailsList = detail.getSubscriptionOfferDetails(); + if (subscriptionOfferDetailsList != null) { + info.put("subscriptionOfferDetails", fromSubscriptionOfferDetailsList(subscriptionOfferDetailsList)); + } + return info; } - // TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. - @SuppressWarnings("deprecation") - static List> fromSkuDetailsList( - @Nullable List skuDetailsList) { - if (skuDetailsList == null) { + static List> fromProductDetailsList( + @Nullable List productDetailsList) { + if (productDetailsList == null) { return Collections.emptyList(); } ArrayList> output = new ArrayList<>(); - for (com.android.billingclient.api.SkuDetails detail : skuDetailsList) { - output.add(fromSkuDetail(detail)); + for (ProductDetails detail : productDetailsList) { + output.add(fromProductDetail(detail)); } return output; } + static HashMap fromOneTimePurchaseOfferDetails(@Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails) { + HashMap serialized = new HashMap<>(); + if (oneTimePurchaseOfferDetails == null) { + return serialized; + } + + serialized.put("priceAmountMicros", oneTimePurchaseOfferDetails.getPriceAmountMicros()); + serialized.put("priceCurrencyCode", oneTimePurchaseOfferDetails.getPriceCurrencyCode()); + serialized.put("formattedPrice", oneTimePurchaseOfferDetails.getFormattedPrice()); + + return serialized; + } + + static List> fromSubscriptionOfferDetailsList(@Nullable List subscriptionOfferDetailsList) { + if (subscriptionOfferDetailsList == null) { + return Collections.emptyList(); + } + + ArrayList> serialized = new ArrayList<>(); + + for (ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails : subscriptionOfferDetailsList) { + serialized.add(fromSubscriptionOfferDetails(subscriptionOfferDetails)); + } + + return serialized; + } + + static HashMap fromSubscriptionOfferDetails(@Nullable ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails) { + HashMap serialized = new HashMap<>(); + if (subscriptionOfferDetails == null) { + return serialized; + } + + serialized.put("offerId", subscriptionOfferDetails.getOfferId()); + serialized.put("basePlanId", subscriptionOfferDetails.getBasePlanId()); + serialized.put("offerTags", subscriptionOfferDetails.getOfferTags()); + serialized.put("offerToken", subscriptionOfferDetails.getOfferToken()); + + ProductDetails.PricingPhases pricingPhases = subscriptionOfferDetails.getPricingPhases(); + serialized.put("pricingPhases", fromPricingPhases(pricingPhases)); + + return serialized; + } + + static List> fromPricingPhases(@NonNull ProductDetails.PricingPhases pricingPhases) { + ArrayList> serialized = new ArrayList<>(); + + for (ProductDetails.PricingPhase pricingPhase : pricingPhases.getPricingPhaseList()) { + serialized.add(fromPricingPhase(pricingPhase)); + } + return serialized; + } + + static HashMap fromPricingPhase(@Nullable ProductDetails.PricingPhase pricingPhase) { + HashMap serialized = new HashMap<>(); + + if (pricingPhase == null) { + return serialized; + } + + serialized.put("formattedPrice", pricingPhase.getFormattedPrice()); + serialized.put("priceCurrencyCode", pricingPhase.getPriceCurrencyCode()); + serialized.put("priceAmountMicros", pricingPhase.getPriceAmountMicros()); + serialized.put("billingCycleCount", pricingPhase.getBillingCycleCount()); + serialized.put("billingPeriod", pricingPhase.getBillingPeriod()); + serialized.put("recurrenceMode", pricingPhase.getRecurrenceMode()); + + return serialized; + } + static HashMap fromPurchase(Purchase purchase) { HashMap info = new HashMap<>(); - // TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. - @SuppressWarnings("deprecation") - List skus = purchase.getSkus(); + List products = purchase.getProducts(); info.put("orderId", purchase.getOrderId()); info.put("packageName", purchase.getPackageName()); info.put("purchaseTime", purchase.getPurchaseTime()); info.put("purchaseToken", purchase.getPurchaseToken()); info.put("signature", purchase.getSignature()); - info.put("skus", skus); + info.put("products", products); info.put("isAutoRenewing", purchase.isAutoRenewing()); info.put("originalJson", purchase.getOriginalJson()); info.put("developerPayload", purchase.getDeveloperPayload()); @@ -84,13 +150,11 @@ static HashMap fromPurchase(Purchase purchase) { static HashMap fromPurchaseHistoryRecord( PurchaseHistoryRecord purchaseHistoryRecord) { HashMap info = new HashMap<>(); - // TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. - @SuppressWarnings("deprecation") - List skus = purchaseHistoryRecord.getSkus(); + List products = purchaseHistoryRecord.getProducts(); info.put("purchaseTime", purchaseHistoryRecord.getPurchaseTime()); info.put("purchaseToken", purchaseHistoryRecord.getPurchaseToken()); info.put("signature", purchaseHistoryRecord.getSignature()); - info.put("skus", skus); + info.put("products", products); info.put("developerPayload", purchaseHistoryRecord.getDeveloperPayload()); info.put("originalJson", purchaseHistoryRecord.getOriginalJson()); info.put("quantity", purchaseHistoryRecord.getQuantity()); diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 7c4498a30eef..a3d1aa037188 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -9,12 +9,12 @@ import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW_V4; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES_ASYNC; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES_ASYNC_V4; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC_V4; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.START_CONNECTION; import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; @@ -77,9 +77,6 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; -// TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. -// This is supressed at the class level since until the code is migrated, the tests will be -// full of deprecation warnings. @SuppressWarnings("deprecation") public class MethodCallHandlerTest { private MethodCallHandlerImpl methodChannelHandler; @@ -285,7 +282,7 @@ public void launchBillingFlow_null_AccountId_do_not_crash() { arguments.put("sku", skuId); arguments.put("accountId", null); arguments.put("obfuscatedProfileId", null); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -317,7 +314,7 @@ public void launchBillingFlow_ok_null_OldSku() { arguments.put("sku", skuId); arguments.put("accountId", accountId); arguments.put("oldSku", null); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -349,7 +346,7 @@ public void launchBillingFlow_ok_null_Activity() { HashMap arguments = new HashMap<>(); arguments.put("sku", skuId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); methodChannelHandler.onMethodCall(launchCall, result); // Verify we pass the response code to result @@ -368,7 +365,7 @@ public void launchBillingFlow_ok_oldSku() { arguments.put("sku", skuId); arguments.put("accountId", accountId); arguments.put("oldSku", oldSkuId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -399,7 +396,7 @@ public void launchBillingFlow_ok_AccountId() { HashMap arguments = new HashMap<>(); arguments.put("sku", skuId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -436,7 +433,7 @@ public void launchBillingFlow_ok_Proration() { arguments.put("oldSku", oldSkuId); arguments.put("purchaseToken", purchaseToken); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -472,7 +469,7 @@ public void launchBillingFlow_ok_Proration_with_null_OldSku() { arguments.put("accountId", accountId); arguments.put("oldSku", oldSkuId); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -507,7 +504,7 @@ public void launchBillingFlow_ok_Full() { arguments.put("oldSku", oldSkuId); arguments.put("purchaseToken", purchaseToken); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); // Launch the billing flow BillingResult billingResult = @@ -539,7 +536,7 @@ public void launchBillingFlow_clientDisconnected() { HashMap arguments = new HashMap<>(); arguments.put("sku", skuId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); methodChannelHandler.onMethodCall(launchCall, result); @@ -557,7 +554,7 @@ public void launchBillingFlow_skuNotFound() { HashMap arguments = new HashMap<>(); arguments.put("sku", skuId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); methodChannelHandler.onMethodCall(launchCall, result); @@ -578,7 +575,7 @@ public void launchBillingFlow_oldSkuNotFound() { arguments.put("sku", skuId); arguments.put("accountId", accountId); arguments.put("oldSku", oldSkuId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); methodChannelHandler.onMethodCall(launchCall, result); @@ -594,7 +591,7 @@ public void queryPurchases_clientDisconnected() { HashMap arguments = new HashMap<>(); arguments.put("skuType", BillingClient.SkuType.INAPP); - methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC, arguments), result); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC_V4, arguments), result); // Assert that we sent an error back. verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); @@ -637,7 +634,7 @@ public Object answer(InvocationOnMock invocation) { HashMap arguments = new HashMap<>(); arguments.put("skuType", BillingClient.SkuType.INAPP); - methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC, arguments), result); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC_V4, arguments), result); lock.await(5000, TimeUnit.MILLISECONDS); @@ -672,7 +669,7 @@ public void queryPurchaseHistoryAsync() { ArgumentCaptor.forClass(PurchaseHistoryResponseListener.class); methodChannelHandler.onMethodCall( - new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC_V4, arguments), result); // Verify we pass the data to result verify(mockBillingClient) @@ -693,7 +690,7 @@ public void queryPurchaseHistoryAsync_clientDisconnected() { HashMap arguments = new HashMap<>(); arguments.put("skuType", BillingClient.SkuType.INAPP); methodChannelHandler.onMethodCall( - new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC_V4, arguments), result); // Assert that we sent an error back. verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index 914ef0b57efa..288aae4cdfab 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -26,9 +26,6 @@ import org.junit.Before; import org.junit.Test; -// TODO(stuartmorgan): Migrate this code. See TODO on MethodCallHandlerImpl.querySkuDetailsAsync. -// This is supressed at the class level since until the code is migrated, the tests will be -// full of deprecation warnings. @SuppressWarnings("deprecation") public class TranslatorTest { private static final String SKU_DETAIL_EXAMPLE_JSON = diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index bd1955526654..e27e1866835c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -260,7 +260,7 @@ class _MyAppState extends State<_MyApp> { InAppPurchasePlatformAddition.instance! as InAppPurchaseAndroidPlatformAddition; final SkuDetailsWrapper skuDetails = - (productDetails as GooglePlayProductDetails) + (productDetails as GooglePlayProductDetailsV4) .skuDetails; addition .launchPriceChangeConfirmationFlow( @@ -283,7 +283,7 @@ class _MyAppState extends State<_MyApp> { // inside the app may not be accurate. final GooglePlayPurchaseDetails? oldSubscription = _getOldSubscription( - productDetails as GooglePlayProductDetails, + productDetails as GooglePlayProductDetailsV4, purchases); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( @@ -434,7 +434,7 @@ class _MyAppState extends State<_MyApp> { } GooglePlayPurchaseDetails? _getOldSubscription( - GooglePlayProductDetails productDetails, + GooglePlayProductDetailsV4 productDetails, Map purchases) { // This is just to demonstrate a subscription upgrade or downgrade. // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'. @@ -503,6 +503,8 @@ class _FeatureCard extends StatelessWidget { return 'inAppItemsOnVR'; case BillingClientFeature.priceChangeConfirmation: return 'priceChangeConfirmation'; + case BillingClientFeature.productDetails: + return 'productDetails'; case BillingClientFeature.subscriptions: return 'subscriptions'; case BillingClientFeature.subscriptionsOnVR: diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart index b49be8fe0fe1..96b0b6793f4e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart @@ -4,5 +4,8 @@ export 'src/billing_client_wrappers/billing_client_manager.dart'; export 'src/billing_client_wrappers/billing_client_wrapper.dart'; +export 'src/billing_client_wrappers/billing_response_wrapper.dart'; +export 'src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.dart'; +export 'src/billing_client_wrappers/product_details_wrapper.dart'; export 'src/billing_client_wrappers/purchase_wrapper.dart'; -export 'src/billing_client_wrappers/sku_details_wrapper.dart'; +export 'src/billing_client_wrappers/subscription_offer_details_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index 04a73f6c5645..eeb4e69beb93 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -10,6 +10,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../billing_client_wrappers.dart'; import '../channel.dart'; +import '../types/product.dart'; part 'billing_client_wrapper.g.dart'; @@ -133,31 +134,36 @@ class BillingClient { return channel.invokeMethod('BillingClient#endConnection()'); } - /// Returns a list of [SkuDetailsWrapper]s that have [SkuDetailsWrapper.sku] - /// in `skusList`, and [SkuDetailsWrapper.type] matching `skuType`. + /// Returns a list of [ProductDetailsResponseWrapper]s that have + /// [ProductDetailsWrapper.productId] and [ProductDetailsWrapper.productType] + /// in `productList`. /// - /// Calls through to [`BillingClient#querySkuDetailsAsync(SkuDetailsParams, - /// SkuDetailsResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querySkuDetailsAsync(com.android.billingclient.api.SkuDetailsParams,%20com.android.billingclient.api.SkuDetailsResponseListener)) + /// Calls through to + /// [`BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryProductDetailsAsync(com.android.billingclient.api.QueryProductDetailsParams,%20com.android.billingclient.api.ProductDetailsResponseListener). /// Instead of taking a callback parameter, it returns a Future - /// [SkuDetailsResponseWrapper]. It also takes the values of - /// `SkuDetailsParams` as direct arguments instead of requiring it constructed - /// and passed in as a class. - Future querySkuDetails( - {required SkuType skuType, required List skusList}) async { + /// [ProductDetailsResponseWrapper]. It also takes the values of + /// `ProductDetailsParams` as direct arguments instead of requiring it + /// constructed and passed in as a class. + Future queryProductDetails({ + required List productList, + }) async { final Map arguments = { - 'skuType': const SkuTypeConverter().toJson(skuType), - 'skusList': skusList + 'productIds': productList.map((Product p) => p.id).toList(), + 'productTypes': productList + .map((Product p) => const ProductTypeConverter().toJson(p.type)) + .toList(), }; - return SkuDetailsResponseWrapper.fromJson((await channel.invokeMapMethod< - String, dynamic>( - 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)', - arguments)) ?? - {}); + return ProductDetailsResponseWrapper.fromJson( + (await channel.invokeMapMethod( + 'BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)', + arguments, + )) ?? + {}); } - /// Attempt to launch the Play Billing Flow for a given [skuDetails]. + /// Attempt to launch the Play Billing Flow for a given [productDetails]. /// - /// The [skuDetails] needs to have already been fetched in a [querySkuDetails] + /// The [productDetails] needs to have already been fetched in a [queryProductDetails] /// call. The [accountId] is an optional hashed string associated with the user /// that's unique to your app. It's used by Google to detect unusual behavior. /// Do not pass in a cleartext [accountId], and do not use this field to store any Personally Identifiable Information (PII) @@ -179,32 +185,38 @@ class BillingClient { /// [`BillingClient#launchBillingFlow`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchbillingflow). /// It constructs a /// [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) - /// instance by [setting the given skuDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setskudetails), + /// instance by [setting the given productDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setProductDetailsParamsList(java.util.List%3Ccom.android.billingclient.api.BillingFlowParams.ProductDetailsParams%3E)), /// [the given accountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)) /// and the [obfuscatedProfileId] (https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid). /// - /// When this method is called to purchase a subscription, an optional `oldSku` - /// can be passed in. This will tell Google Play that rather than purchasing a new subscription, - /// the user needs to upgrade/downgrade the existing subscription. - /// The [oldSku](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setoldsku) and [purchaseToken] are the SKU id and purchase token that the user is upgrading or downgrading from. - /// [purchaseToken] must not be `null` if [oldSku] is not `null`. - /// The [prorationMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setreplaceskusprorationmode) is the mode of proration during subscription upgrade/downgrade. - /// This value will only be effective if the `oldSku` is also set. + /// When this method is called to purchase a subscription through an offer, an + /// [`offerToken` can be passed in](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.Builder#setOfferToken(java.lang.String)). + /// + /// When this method is called to purchase a subscription, an optional + /// `oldProduct` can be passed in. This will tell Google Play that rather than + /// purchasing a new subscription, the user needs to upgrade/downgrade the + /// existing subscription. + /// The [oldProduct](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOldPurchaseToken(java.lang.String)) and [purchaseToken] are the product id and purchase token that the user is upgrading or downgrading from. + /// [purchaseToken] must not be `null` if [oldProduct] is not `null`. + /// The [prorationMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setReplaceProrationMode(int)) is the mode of proration during subscription upgrade/downgrade. + /// This value will only be effective if the `oldProduct` is also set. Future launchBillingFlow( - {required String sku, + {required String product, + String? offerToken, String? accountId, String? obfuscatedProfileId, - String? oldSku, + String? oldProduct, String? purchaseToken, ProrationMode? prorationMode}) async { - assert(sku != null); - assert((oldSku == null) == (purchaseToken == null), - 'oldSku and purchaseToken must both be set, or both be null.'); + assert(product != null); + assert((oldProduct == null) == (purchaseToken == null), + 'oldProduct and purchaseToken must both be set, or both be null.'); final Map arguments = { - 'sku': sku, + 'product': product, + 'offerToken': offerToken, 'accountId': accountId, 'obfuscatedProfileId': obfuscatedProfileId, - 'oldSku': oldSku, + 'oldProduct': oldProduct, 'purchaseToken': purchaseToken, 'prorationMode': const ProrationModeConverter().toJson(prorationMode ?? ProrationMode.unknownSubscriptionUpgradeDowngradePolicy) @@ -216,7 +228,7 @@ class BillingClient { {}); } - /// Fetches recent purchases for the given [SkuType]. + /// Fetches recent purchases for the given [ProductType]. /// /// Unlike [queryPurchaseHistory], This does not make a network request and /// does not return items that are no longer owned. @@ -226,37 +238,38 @@ class BillingClient { /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). /// /// This wraps [`BillingClient#queryPurchases(String - /// skutype)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchases). - Future queryPurchases(SkuType skuType) async { - assert(skuType != null); + /// productType)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchasesAsync(com.android.billingclient.api.QueryPurchasesParams,%20com.android.billingclient.api.PurchasesResponseListener)). + Future queryPurchases(ProductType productType) async { + assert(productType != null); return PurchasesResultWrapper.fromJson((await channel .invokeMapMethod( 'BillingClient#queryPurchases(String)', { - 'skuType': const SkuTypeConverter().toJson(skuType) + 'productType': const ProductTypeConverter().toJson(productType) })) ?? {}); } - /// Fetches purchase history for the given [SkuType]. + /// Fetches purchase history for the given [ProductType]. /// /// Unlike [queryPurchases], this makes a network request via Play and returns - /// the most recent purchase for each [SkuDetailsWrapper] of the given - /// [SkuType] even if the item is no longer owned. + /// the most recent purchase for each [ProductDetailsWrapper] of the given + /// [ProductType] even if the item is no longer owned. /// /// All purchase information should also be verified manually, with your /// server if at all possible. See ["Verify a /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). /// - /// This wraps [`BillingClient#queryPurchaseHistoryAsync(String skuType, + /// This wraps [`BillingClient#queryPurchaseHistoryAsync(String productType, /// PurchaseHistoryResponseListener - /// listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchasehistoryasync). - Future queryPurchaseHistory(SkuType skuType) async { - assert(skuType != null); - return PurchasesHistoryResult.fromJson((await channel.invokeMapMethod< - String, dynamic>( - 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)', - { - 'skuType': const SkuTypeConverter().toJson(skuType) + /// listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchaseHistoryAsync(com.android.billingclient.api.QueryPurchaseHistoryParams,%20com.android.billingclient.api.PurchaseHistoryResponseListener)). + Future queryPurchaseHistory( + ProductType productType) async { + assert(productType != null); + return PurchasesHistoryResult.fromJson((await channel + .invokeMapMethod( + 'BillingClient#queryPurchaseHistoryAsync(String)', + { + 'productType': const ProductTypeConverter().toJson(productType) })) ?? {}); } @@ -316,26 +329,6 @@ class BillingClient { return result ?? false; } - /// Initiates a flow to confirm the change of price for an item subscribed by the user. - /// - /// When the price of a user subscribed item has changed, launch this flow to take users to - /// a screen with price change information. User can confirm the new price or cancel the flow. - /// - /// The skuDetails needs to have already been fetched in a [querySkuDetails] - /// call. - Future launchPriceChangeConfirmationFlow( - {required String sku}) async { - assert(sku != null); - final Map arguments = { - 'sku': sku, - }; - return BillingResultWrapper.fromJson((await channel.invokeMapMethod( - 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)', - arguments)) ?? - {}); - } - /// The method call handler for [channel]. @visibleForTesting Future callHandler(MethodCall call) async { @@ -448,13 +441,13 @@ class BillingResponseConverter implements JsonConverter { int toJson(BillingResponse object) => _$BillingResponseEnumMap[object]!; } -/// Enum representing potential [SkuDetailsWrapper.type]s. +/// Enum representing potential [ProductDetailsWrapper.productType]s. /// /// Wraps -/// [`BillingClient.SkuType`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.SkuType) +/// [`BillingClient.ProductType`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.ProductType) /// See the linked documentation for an explanation of the different constants. @JsonEnum(alwaysCreate: true) -enum SkuType { +enum ProductType { // WARNING: Changes to this class need to be reflected in our generated code. // Run `flutter packages pub run build_runner watch` to rebuild and watch for // further changes. @@ -468,24 +461,24 @@ enum SkuType { subs, } -/// Serializer for [SkuType]. +/// Serializer for [ProductType]. /// /// Use these in `@JsonSerializable()` classes by annotating them with -/// `@SkuTypeConverter()`. -class SkuTypeConverter implements JsonConverter { +/// `@ProductTypeConverter()`. +class ProductTypeConverter implements JsonConverter { /// Default const constructor. - const SkuTypeConverter(); + const ProductTypeConverter(); @override - SkuType fromJson(String? json) { + ProductType fromJson(String? json) { if (json == null) { - return SkuType.inapp; + return ProductType.inapp; } - return $enumDecode(_$SkuTypeEnumMap, json); + return $enumDecode(_$ProductTypeEnumMap, json); } @override - String toJson(SkuType object) => _$SkuTypeEnumMap[object]!; + String toJson(ProductType object) => _$ProductTypeEnumMap[object]!; } /// Enum representing the proration mode. @@ -564,8 +557,9 @@ enum BillingClientFeature { // WARNING: Changes to this class need to be reflected in our generated code. // Run `flutter packages pub run build_runner watch` to rebuild and watch for // further changes. - + // // JsonValues need to match constant values defined in https://developer.android.com/reference/com/android/billingclient/api/BillingClient.FeatureType#summary + /// Purchase/query for in-app items on VR. @JsonValue('inAppItemsOnVr') inAppItemsOnVR, @@ -574,6 +568,10 @@ enum BillingClientFeature { @JsonValue('priceChangeConfirmation') priceChangeConfirmation, + /// Play billing library support for querying and purchasing with ProductDetails. + @JsonValue('fff') + productDetails, + /// Purchase/query for subscriptions. @JsonValue('subscriptions') subscriptions, diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart index 99355a1b91fb..7f636f034347 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart @@ -21,9 +21,9 @@ const _$BillingResponseEnumMap = { BillingResponse.itemNotOwned: 8, }; -const _$SkuTypeEnumMap = { - SkuType.inapp: 'inapp', - SkuType.subs: 'subs', +const _$ProductTypeEnumMap = { + ProductType.inapp: 'inapp', + ProductType.subs: 'subs', }; const _$ProrationModeEnumMap = { @@ -38,6 +38,7 @@ const _$ProrationModeEnumMap = { const _$BillingClientFeatureEnumMap = { BillingClientFeature.inAppItemsOnVR: 'inAppItemsOnVr', BillingClientFeature.priceChangeConfirmation: 'priceChangeConfirmation', + BillingClientFeature.productDetails: 'fff', BillingClientFeature.subscriptions: 'subscriptions', BillingClientFeature.subscriptionsOnVR: 'subscriptionsOnVr', BillingClientFeature.subscriptionsUpdate: 'subscriptionsUpdate', diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart new file mode 100644 index 000000000000..da4f2a814797 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../billing_client_wrappers.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'billing_response_wrapper.g.dart'; + +/// The error message shown when the map represents billing result is invalid from method channel. +/// +/// This usually indicates a series underlining code issue in the plugin. +@visibleForTesting +const String kInvalidBillingResultErrorMessage = + 'Invalid billing result map from method channel.'; + +/// Params containing the response code and the debug message from the Play Billing API response. +@JsonSerializable() +@BillingResponseConverter() +@immutable +class BillingResultWrapper implements HasBillingResponse { + /// Constructs the object with [responseCode] and [debugMessage]. + const BillingResultWrapper({required this.responseCode, this.debugMessage}); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. + factory BillingResultWrapper.fromJson(Map? map) { + if (map == null || map.isEmpty) { + return const BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage); + } + return _$BillingResultWrapperFromJson(map); + } + + /// Response code returned in the Play Billing API calls. + @override + final BillingResponse responseCode; + + /// Debug message returned in the Play Billing API calls. + /// + /// Defaults to `null`. + /// This message uses an en-US locale and should not be shown to users. + final String? debugMessage; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is BillingResultWrapper && + other.responseCode == responseCode && + other.debugMessage == debugMessage; + } + + @override + int get hashCode => Object.hash(responseCode, debugMessage); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.g.dart new file mode 100644 index 000000000000..bff62ae85744 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'billing_response_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BillingResultWrapper _$BillingResultWrapperFromJson(Map json) => + BillingResultWrapper( + responseCode: const BillingResponseConverter() + .fromJson(json['responseCode'] as int?), + debugMessage: json['debugMessage'] as String?, + ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.dart new file mode 100644 index 000000000000..f5ceed22ebe9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.dart @@ -0,0 +1,74 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'one_time_purchase_offer_details_wrapper.g.dart'; + +/// Dart wrapper around [`com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.OneTimePurchaseOfferDetails). +/// +/// Represents the offer details to buy a one-time purchase product. +@JsonSerializable() +@immutable +class OneTimePurchaseOfferDetailsWrapper { + /// Creates a [OneTimePurchaseOfferDetailsWrapper]. + @visibleForTesting + const OneTimePurchaseOfferDetailsWrapper({ + required this.formattedPrice, + required this.priceAmountMicros, + required this.priceCurrencyCode, + }); + + /// Factory for creating a [OneTimePurchaseOfferDetailsWrapper] from a [Map] + /// with the offer details. + factory OneTimePurchaseOfferDetailsWrapper.fromJson( + Map map) => + _$OneTimePurchaseOfferDetailsWrapperFromJson(map); + + /// Formatted price for the payment, including its currency sign. + /// + /// For tax exclusive countries, the price doesn't include tax. + @JsonKey(defaultValue: '') + final String formattedPrice; + + /// The price for the payment in micro-units, where 1,000,000 micro-units + /// equal one unit of the currency. + /// + /// For example, if price is "€7.99", price_amount_micros is "7990000". This + /// value represents the localized, rounded price for a particular currency. + @JsonKey(defaultValue: 0) + final int priceAmountMicros; + + /// The ISO 4217 currency code for price. + /// + /// For example, if price is specified in British pounds sterling, currency + /// code is "GBP". + @JsonKey(defaultValue: '') + final String priceCurrencyCode; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is OneTimePurchaseOfferDetailsWrapper && + other.formattedPrice == formattedPrice && + other.priceAmountMicros == priceAmountMicros && + other.priceCurrencyCode == priceCurrencyCode; + } + + @override + int get hashCode { + return Object.hash( + formattedPrice.hashCode, + priceAmountMicros.hashCode, + priceCurrencyCode.hashCode, + ); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.g.dart new file mode 100644 index 000000000000..19e57e80157b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/one_time_purchase_offer_details_wrapper.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'one_time_purchase_offer_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OneTimePurchaseOfferDetailsWrapper _$OneTimePurchaseOfferDetailsWrapperFromJson( + Map json) => + OneTimePurchaseOfferDetailsWrapper( + formattedPrice: json['formattedPrice'] as String? ?? '', + priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, + priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart new file mode 100644 index 000000000000..1ebdf263d668 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart @@ -0,0 +1,190 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../billing_client_wrappers.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'product_details_wrapper.g.dart'; + +/// Dart wrapper around [`com.android.billingclient.api.ProductDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails). +/// +/// Contains the details of an available product in Google Play Billing. +/// Represents the details of a one-time or subscription product. +@JsonSerializable() +@ProductTypeConverter() +@immutable +class ProductDetailsWrapper { + /// Creates a [ProductDetailsWrapper] with the given purchase details. + @visibleForTesting + const ProductDetailsWrapper({ + required this.description, + required this.name, + this.oneTimePurchaseOfferDetails, + required this.productId, + required this.productType, + this.subscriptionOfferDetails, + required this.title, + }); + + /// Factory for creating a [ProductDetailsWrapper] from a [Map] with the + /// product details. + factory ProductDetailsWrapper.fromJson(Map map) => + _$ProductDetailsWrapperFromJson(map); + + /// Textual description of the product. + @JsonKey(defaultValue: '') + final String description; + + /// The name of the product being sold. + /// + /// Similar to [title], but does not include the name of the app which owns + /// the product. Example: 100 Gold Coins. + @JsonKey(defaultValue: '') + final String name; + + /// The offer details of a one-time purchase product. + /// + /// [oneTimePurchaseOfferDetails] is only set for [ProductType.inapp]. Returns + /// null for [ProductType.subs]. + @JsonKey(defaultValue: null) + final OneTimePurchaseOfferDetailsWrapper? oneTimePurchaseOfferDetails; + + /// The product's id. + @JsonKey(defaultValue: '') + final String productId; + + /// The [ProductType] of the product. + @JsonKey(defaultValue: ProductType.subs) + final ProductType productType; + + /// A list containing all available offers to purchase a subscription product. + /// + /// [subscriptionOfferDetails] is only set for [ProductType.subs]. Returns + /// null for [ProductType.inapp]. + @JsonKey(defaultValue: null) + final List? subscriptionOfferDetails; + + /// The title of the product being sold. + /// + /// Similar to [name], but includes the name of the app which owns the + /// product. Example: 100 Gold Coins (Coin selling app). + @JsonKey(defaultValue: '') + final String title; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is ProductDetailsWrapper && + other.description == description && + other.name == name && + other.oneTimePurchaseOfferDetails == oneTimePurchaseOfferDetails && + other.productId == productId && + other.productType == productType && + other.subscriptionOfferDetails == subscriptionOfferDetails && + other.title == title; + } + + @override + int get hashCode { + return Object.hash( + description.hashCode, + name.hashCode, + oneTimePurchaseOfferDetails.hashCode, + productId.hashCode, + productType.hashCode, + subscriptionOfferDetails.hashCode, + title.hashCode, + ); + } +} + +/// Translation of [`com.android.billingclient.api.ProductDetailsResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetailsResponseListener.html). +/// +/// Returned by [BillingClient.queryProductDetails]. +@JsonSerializable() +@immutable +class ProductDetailsResponseWrapper implements HasBillingResponse { + /// Creates a [ProductDetailsResponseWrapper] with the given purchase details. + const ProductDetailsResponseWrapper({ + required this.billingResult, + required this.productDetailsList, + }); + + /// Constructs an instance of this from a key value map of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. + factory ProductDetailsResponseWrapper.fromJson(Map map) => + _$ProductDetailsResponseWrapperFromJson(map); + + /// The final result of the [BillingClient.queryProductDetails] call. + final BillingResultWrapper billingResult; + + /// A list of [ProductDetailsWrapper] matching the query to [BillingClient.queryProductDetails]. + @JsonKey(defaultValue: []) + final List productDetailsList; + + @override + BillingResponse get responseCode => billingResult.responseCode; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is ProductDetailsResponseWrapper && + other.billingResult == billingResult && + other.productDetailsList == productDetailsList; + } + + @override + int get hashCode => Object.hash(billingResult, productDetailsList); +} + +/// Recurrence mode of the pricing phase. +@JsonEnum(alwaysCreate: true) +enum RecurrenceMode { + /// The billing plan payment recurs for a fixed number of billing period set + /// in billingCycleCount. + @JsonValue(2) + finiteRecurring, + + /// The billing plan payment recurs for infinite billing periods unless + /// cancelled. + @JsonValue(1) + infiniteRecurring, + + /// The billing plan payment is a one time charge that does not repeat. + @JsonValue(3) + nonRecurring, +} + +/// Serializer for [RecurrenceMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@RecurrenceModeConverter()`. +class RecurrenceModeConverter implements JsonConverter { + /// Default const constructor. + const RecurrenceModeConverter(); + + @override + RecurrenceMode fromJson(int? json) { + if (json == null) { + return RecurrenceMode.nonRecurring; + } + return $enumDecode(_$RecurrenceModeEnumMap, json); + } + + @override + int toJson(RecurrenceMode object) => _$RecurrenceModeEnumMap[object]!; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.g.dart new file mode 100644 index 000000000000..de8079d81571 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'product_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProductDetailsWrapper _$ProductDetailsWrapperFromJson(Map json) => + ProductDetailsWrapper( + description: json['description'] as String? ?? '', + name: json['name'] as String? ?? '', + oneTimePurchaseOfferDetails: json['oneTimePurchaseOfferDetails'] == null + ? null + : OneTimePurchaseOfferDetailsWrapper.fromJson( + Map.from( + json['oneTimePurchaseOfferDetails'] as Map)), + productId: json['productId'] as String? ?? '', + productType: json['productType'] == null + ? ProductType.subs + : const ProductTypeConverter() + .fromJson(json['productType'] as String?), + subscriptionOfferDetails: + (json['subscriptionOfferDetails'] as List?) + ?.map((e) => SubscriptionOfferDetailsWrapper.fromJson( + Map.from(e as Map))) + .toList(), + title: json['title'] as String? ?? '', + ); + +ProductDetailsResponseWrapper _$ProductDetailsResponseWrapperFromJson( + Map json) => + ProductDetailsResponseWrapper( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + productDetailsList: (json['productDetailsList'] as List?) + ?.map((e) => ProductDetailsWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); + +const _$RecurrenceModeEnumMap = { + RecurrenceMode.finiteRecurring: 2, + RecurrenceMode.infiniteRecurring: 1, + RecurrenceMode.nonRecurring: 3, +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart index 633aa732165b..97fde8a8755a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -6,9 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'billing_client_manager.dart'; -import 'billing_client_wrapper.dart'; -import 'sku_details_wrapper.dart'; +import '../../billing_client_wrappers.dart'; // WARNING: Changes to `@JsonSerializable` classes need to be reflected in the // below generated file. Run `flutter packages pub run build_runner watch` to @@ -34,8 +32,7 @@ class PurchaseWrapper { required this.purchaseTime, required this.purchaseToken, required this.signature, - @Deprecated('Use skus instead') String? sku, - required this.skus, + required this.products, required this.isAutoRenewing, required this.originalJson, this.developerPayload, @@ -43,7 +40,7 @@ class PurchaseWrapper { required this.purchaseState, this.obfuscatedAccountId, this.obfuscatedProfileId, - }) : _sku = sku; + }); /// Factory for creating a [PurchaseWrapper] from a [Map] with the purchase details. factory PurchaseWrapper.fromJson(Map map) => @@ -63,7 +60,7 @@ class PurchaseWrapper { other.purchaseTime == purchaseTime && other.purchaseToken == purchaseToken && other.signature == signature && - other.sku == sku && + listEquals(other.products, products) && other.isAutoRenewing == isAutoRenewing && other.originalJson == originalJson && other.isAcknowledged == isAcknowledged && @@ -77,7 +74,7 @@ class PurchaseWrapper { purchaseTime, purchaseToken, signature, - sku, + products.hashCode, isAutoRenewing, originalJson, isAcknowledged, @@ -96,7 +93,7 @@ class PurchaseWrapper { @JsonKey(defaultValue: 0) final int purchaseTime; - /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + /// A unique ID for a given [ProductDetailsWrapper], user, and purchase. @JsonKey(defaultValue: '') final String purchaseToken; @@ -105,23 +102,17 @@ class PurchaseWrapper { @JsonKey(defaultValue: '') final String signature; - /// The product ID of this purchase. - @Deprecated('Use skus instead') - @JsonKey(ignore: true) - String get sku => _sku ?? (skus.isNotEmpty ? skus.first : ''); - final String? _sku; - /// The product IDs of this purchase. @JsonKey(defaultValue: []) - final List skus; + final List products; /// True for subscriptions that renew automatically. Does not apply to - /// [SkuType.inapp] products. + /// [ProductType.inapp] products. /// - /// For [SkuType.subs] this means that the subscription is canceled when it is + /// For [ProductType.subs] this means that the subscription is canceled when it is /// false. /// - /// The value is `false` for [SkuType.inapp] products. + /// The value is `false` for [ProductType.inapp] products. final bool isAutoRenewing; /// Details about this purchase, in JSON. @@ -186,11 +177,10 @@ class PurchaseHistoryRecordWrapper { required this.purchaseTime, required this.purchaseToken, required this.signature, - @Deprecated('Use skus instead') String? sku, - required this.skus, + required this.products, required this.originalJson, required this.developerPayload, - }) : _sku = sku; + }); /// Factory for creating a [PurchaseHistoryRecordWrapper] from a [Map] with the record details. factory PurchaseHistoryRecordWrapper.fromJson(Map map) => @@ -200,7 +190,7 @@ class PurchaseHistoryRecordWrapper { @JsonKey(defaultValue: 0) final int purchaseTime; - /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + /// A unique ID for a given [ProductDetailsWrapper], user, and purchase. @JsonKey(defaultValue: '') final String purchaseToken; @@ -209,16 +199,9 @@ class PurchaseHistoryRecordWrapper { @JsonKey(defaultValue: '') final String signature; - /// The product ID of this purchase. - @Deprecated('Use skus instead') - @JsonKey(ignore: true) - String get sku => _sku ?? (skus.isNotEmpty ? skus.first : ''); - - final String? _sku; - /// The product ID of this purchase. @JsonKey(defaultValue: []) - final List skus; + final List products; /// Details about this purchase, in JSON. /// @@ -246,14 +229,20 @@ class PurchaseHistoryRecordWrapper { other.purchaseTime == purchaseTime && other.purchaseToken == purchaseToken && other.signature == signature && - other.sku == sku && + listEquals(other.products, products) && other.originalJson == originalJson && other.developerPayload == developerPayload; } @override - int get hashCode => Object.hash(purchaseTime, purchaseToken, signature, sku, - originalJson, developerPayload); + int get hashCode => Object.hash( + purchaseTime, + purchaseToken, + signature, + products.hashCode, + originalJson, + developerPayload, + ); } /// A data struct representing the result of a transaction. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart index ad2a909fbfdc..0270d610eb68 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -12,9 +12,10 @@ PurchaseWrapper _$PurchaseWrapperFromJson(Map json) => PurchaseWrapper( purchaseTime: json['purchaseTime'] as int? ?? 0, purchaseToken: json['purchaseToken'] as String? ?? '', signature: json['signature'] as String? ?? '', - skus: - (json['skus'] as List?)?.map((e) => e as String).toList() ?? - [], + products: (json['products'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], isAutoRenewing: json['isAutoRenewing'] as bool, originalJson: json['originalJson'] as String? ?? '', developerPayload: json['developerPayload'] as String?, @@ -30,9 +31,10 @@ PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) => purchaseTime: json['purchaseTime'] as int? ?? 0, purchaseToken: json['purchaseToken'] as String? ?? '', signature: json['signature'] as String? ?? '', - skus: - (json['skus'] as List?)?.map((e) => e as String).toList() ?? - [], + products: (json['products'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], originalJson: json['originalJson'] as String? ?? '', developerPayload: json['developerPayload'] as String?, ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart deleted file mode 100644 index 2689cf37eac4..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/foundation.dart'; -import 'package:json_annotation/json_annotation.dart'; - -import 'billing_client_manager.dart'; -import 'billing_client_wrapper.dart'; - -// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the -// below generated file. Run `flutter packages pub run build_runner watch` to -// rebuild and watch for further changes. -part 'sku_details_wrapper.g.dart'; - -/// The error message shown when the map represents billing result is invalid from method channel. -/// -/// This usually indicates a series underlining code issue in the plugin. -@visibleForTesting -const String kInvalidBillingResultErrorMessage = - 'Invalid billing result map from method channel.'; - -/// Dart wrapper around [`com.android.billingclient.api.SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails). -/// -/// Contains the details of an available product in Google Play Billing. -@JsonSerializable() -@SkuTypeConverter() -@immutable -class SkuDetailsWrapper { - /// Creates a [SkuDetailsWrapper] with the given purchase details. - @visibleForTesting - const SkuDetailsWrapper({ - required this.description, - required this.freeTrialPeriod, - required this.introductoryPrice, - @Deprecated('Use `introductoryPriceAmountMicros` parameter instead') - String introductoryPriceMicros = '', - this.introductoryPriceAmountMicros = 0, - required this.introductoryPriceCycles, - required this.introductoryPricePeriod, - required this.price, - required this.priceAmountMicros, - required this.priceCurrencyCode, - required this.priceCurrencySymbol, - required this.sku, - required this.subscriptionPeriod, - required this.title, - required this.type, - required this.originalPrice, - required this.originalPriceAmountMicros, - }) : _introductoryPriceMicros = introductoryPriceMicros; - - /// Constructs an instance of this from a key value map of data. - /// - /// The map needs to have named string keys with values matching the names and - /// types of all of the members on this class. - @visibleForTesting - factory SkuDetailsWrapper.fromJson(Map map) => - _$SkuDetailsWrapperFromJson(map); - - final String _introductoryPriceMicros; - - /// Textual description of the product. - @JsonKey(defaultValue: '') - final String description; - - /// Trial period in ISO 8601 format. - @JsonKey(defaultValue: '') - final String freeTrialPeriod; - - /// Introductory price, only applies to [SkuType.subs]. Formatted ("$0.99"). - @JsonKey(defaultValue: '') - final String introductoryPrice; - - /// [introductoryPrice] in micro-units 990000. - /// - /// Returns 0 if the SKU is not a subscription or doesn't have an introductory - /// period. - final int introductoryPriceAmountMicros; - - /// String representation of [introductoryPrice] in micro-units 990000 - @Deprecated('Use `introductoryPriceAmountMicros` instead.') - @JsonKey(ignore: true) - String get introductoryPriceMicros => _introductoryPriceMicros.isEmpty - ? introductoryPriceAmountMicros.toString() - : _introductoryPriceMicros; - - /// The number of subscription billing periods for which the user will be given the introductory price, such as 3. - /// Returns 0 if the SKU is not a subscription or doesn't have an introductory period. - @JsonKey(defaultValue: 0) - final int introductoryPriceCycles; - - /// The billing period of [introductoryPrice], in ISO 8601 format. - @JsonKey(defaultValue: '') - final String introductoryPricePeriod; - - /// Formatted with currency symbol ("$0.99"). - @JsonKey(defaultValue: '') - final String price; - - /// [price] in micro-units ("990000"). - @JsonKey(defaultValue: 0) - final int priceAmountMicros; - - /// [price] ISO 4217 currency code. - @JsonKey(defaultValue: '') - final String priceCurrencyCode; - - /// [price] localized currency symbol - /// For example, for the US Dollar, the symbol is "$" if the locale - /// is the US, while for other locales it may be "US$". - @JsonKey(defaultValue: '') - final String priceCurrencySymbol; - - /// The product ID in Google Play Console. - @JsonKey(defaultValue: '') - final String sku; - - /// Applies to [SkuType.subs], formatted in ISO 8601. - @JsonKey(defaultValue: '') - final String subscriptionPeriod; - - /// The product's title. - @JsonKey(defaultValue: '') - final String title; - - /// The [SkuType] of the product. - final SkuType type; - - /// The original price that the user purchased this product for. - @JsonKey(defaultValue: '') - final String originalPrice; - - /// [originalPrice] in micro-units ("990000"). - @JsonKey(defaultValue: 0) - final int originalPriceAmountMicros; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - - return other is SkuDetailsWrapper && - other.description == description && - other.freeTrialPeriod == freeTrialPeriod && - other.introductoryPrice == introductoryPrice && - other.introductoryPriceAmountMicros == introductoryPriceAmountMicros && - other.introductoryPriceCycles == introductoryPriceCycles && - other.introductoryPricePeriod == introductoryPricePeriod && - other.price == price && - other.priceAmountMicros == priceAmountMicros && - other.sku == sku && - other.subscriptionPeriod == subscriptionPeriod && - other.title == title && - other.type == type && - other.originalPrice == originalPrice && - other.originalPriceAmountMicros == originalPriceAmountMicros; - } - - @override - int get hashCode { - return Object.hash( - description.hashCode, - freeTrialPeriod.hashCode, - introductoryPrice.hashCode, - introductoryPriceAmountMicros.hashCode, - introductoryPriceCycles.hashCode, - introductoryPricePeriod.hashCode, - price.hashCode, - priceAmountMicros.hashCode, - sku.hashCode, - subscriptionPeriod.hashCode, - title.hashCode, - type.hashCode, - originalPrice, - originalPriceAmountMicros); - } -} - -/// Translation of [`com.android.billingclient.api.SkuDetailsResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetailsResponseListener.html). -/// -/// Returned by [BillingClient.querySkuDetails]. -@JsonSerializable() -@immutable -class SkuDetailsResponseWrapper implements HasBillingResponse { - /// Creates a [SkuDetailsResponseWrapper] with the given purchase details. - @visibleForTesting - const SkuDetailsResponseWrapper( - {required this.billingResult, required this.skuDetailsList}); - - /// Constructs an instance of this from a key value map of data. - /// - /// The map needs to have named string keys with values matching the names and - /// types of all of the members on this class. - factory SkuDetailsResponseWrapper.fromJson(Map map) => - _$SkuDetailsResponseWrapperFromJson(map); - - /// The final result of the [BillingClient.querySkuDetails] call. - final BillingResultWrapper billingResult; - - /// A list of [SkuDetailsWrapper] matching the query to [BillingClient.querySkuDetails]. - @JsonKey(defaultValue: []) - final List skuDetailsList; - - @override - BillingResponse get responseCode => billingResult.responseCode; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - - return other is SkuDetailsResponseWrapper && - other.billingResult == billingResult && - other.skuDetailsList == skuDetailsList; - } - - @override - int get hashCode => Object.hash(billingResult, skuDetailsList); -} - -/// Params containing the response code and the debug message from the Play Billing API response. -@JsonSerializable() -@BillingResponseConverter() -@immutable -class BillingResultWrapper implements HasBillingResponse { - /// Constructs the object with [responseCode] and [debugMessage]. - const BillingResultWrapper({required this.responseCode, this.debugMessage}); - - /// Constructs an instance of this from a key value map of data. - /// - /// The map needs to have named string keys with values matching the names and - /// types of all of the members on this class. - factory BillingResultWrapper.fromJson(Map? map) { - if (map == null || map.isEmpty) { - return const BillingResultWrapper( - responseCode: BillingResponse.error, - debugMessage: kInvalidBillingResultErrorMessage); - } - return _$BillingResultWrapperFromJson(map); - } - - /// Response code returned in the Play Billing API calls. - @override - final BillingResponse responseCode; - - /// Debug message returned in the Play Billing API calls. - /// - /// Defaults to `null`. - /// This message uses an en-US locale and should not be shown to users. - final String? debugMessage; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - - return other is BillingResultWrapper && - other.responseCode == responseCode && - other.debugMessage == debugMessage; - } - - @override - int get hashCode => Object.hash(responseCode, debugMessage); -} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart deleted file mode 100644 index 05eb6bed0035..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'sku_details_wrapper.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) => SkuDetailsWrapper( - description: json['description'] as String? ?? '', - freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', - introductoryPrice: json['introductoryPrice'] as String? ?? '', - introductoryPriceAmountMicros: - json['introductoryPriceAmountMicros'] as int? ?? 0, - introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, - introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', - price: json['price'] as String? ?? '', - priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, - priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', - priceCurrencySymbol: json['priceCurrencySymbol'] as String? ?? '', - sku: json['sku'] as String? ?? '', - subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', - title: json['title'] as String? ?? '', - type: const SkuTypeConverter().fromJson(json['type'] as String?), - originalPrice: json['originalPrice'] as String? ?? '', - originalPriceAmountMicros: json['originalPriceAmountMicros'] as int? ?? 0, - ); - -SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) => - SkuDetailsResponseWrapper( - billingResult: - BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( - (k, e) => MapEntry(k as String, e), - )), - skuDetailsList: (json['skuDetailsList'] as List?) - ?.map((e) => SkuDetailsWrapper.fromJson( - Map.from(e as Map))) - .toList() ?? - [], - ); - -BillingResultWrapper _$BillingResultWrapperFromJson(Map json) => - BillingResultWrapper( - responseCode: const BillingResponseConverter() - .fromJson(json['responseCode'] as int?), - debugMessage: json['debugMessage'] as String?, - ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart new file mode 100644 index 000000000000..1a3c41181682 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart @@ -0,0 +1,158 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import 'billing_client_wrapper.dart'; +import 'product_details_wrapper.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'subscription_offer_details_wrapper.g.dart'; + +/// Dart wrapper around [`com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails`](https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.SubscriptionOfferDetails). +/// +/// Represents the available purchase plans to buy a subscription product. +@JsonSerializable() +@immutable +class SubscriptionOfferDetailsWrapper { + /// Creates a [SubscriptionOfferDetailsWrapper]. + @visibleForTesting + const SubscriptionOfferDetailsWrapper({ + required this.basePlanId, + this.offerId, + required this.offerTags, + required this.offerToken, + required this.pricingPhases, + }); + + /// Factory for creating a [SubscriptionOfferDetailsWrapper] from a [Map] + /// with the offer details. + factory SubscriptionOfferDetailsWrapper.fromJson(Map map) => + _$SubscriptionOfferDetailsWrapperFromJson(map); + + /// The base plan id associated with the subscription product. + @JsonKey(defaultValue: '') + final String basePlanId; + + /// The offer id associated with the subscription product. + /// + /// This field is only set for a discounted offer. Returns null for a regular + /// base plan. + @JsonKey(defaultValue: null) + final String? offerId; + + /// The offer tags associated with this Subscription Offer. + @JsonKey(defaultValue: []) + final List offerTags; + + /// The offer token required to pass in [BillingClient.launchBillingFlow] to + /// purchase the subscription product with these [pricingPhases]. + @JsonKey(defaultValue: '') + final String offerToken; + + /// The pricing phases for the subscription product. + @JsonKey(defaultValue: []) + final List pricingPhases; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is SubscriptionOfferDetailsWrapper && + other.basePlanId == basePlanId && + other.offerId == offerId && + listEquals(other.offerTags, offerTags) && + other.offerToken == offerToken && + other.pricingPhases == pricingPhases; + } + + @override + int get hashCode { + return Object.hash( + basePlanId.hashCode, + offerId.hashCode, + offerTags.hashCode, + offerToken.hashCode, + pricingPhases.hashCode, + ); + } +} + +/// Represents a pricing phase, describing how a user pays at a point in time. +@JsonSerializable() +@RecurrenceModeConverter() +@immutable +class PricingPhase { + /// Creates a new [PricingPhase] from the supplied info. + @visibleForTesting + const PricingPhase({ + required this.billingCycleCount, + required this.billingPeriod, + required this.formattedPrice, + required this.priceAmountMicros, + required this.priceCurrencyCode, + required this.recurrenceMode, + }); + + /// Factory for creating a [PricingPhase] from a [Map] with the phase details. + factory PricingPhase.fromJson(Map map) => + _$PricingPhaseFromJson(map); + + /// Represents a pricing phase, describing how a user pays at a point in time. + @JsonKey(defaultValue: 0) + final int billingCycleCount; + + /// Billing period for which the given price applies, specified in ISO 8601 + /// format. + @JsonKey(defaultValue: '') + final String billingPeriod; + + /// Returns formatted price for the payment cycle, including its currency + /// sign. + @JsonKey(defaultValue: '') + final String formattedPrice; + + /// Returns the price for the payment cycle in micro-units, where 1,000,000 + /// micro-units equal one unit of the currency. + @JsonKey(defaultValue: 0) + final int priceAmountMicros; + + /// Returns ISO 4217 currency code for price. + @JsonKey(defaultValue: '') + final String priceCurrencyCode; + + /// Returns [RecurrenceMode] for the pricing phase. + @JsonKey(defaultValue: RecurrenceMode.nonRecurring) + final RecurrenceMode recurrenceMode; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is PricingPhase && + other.billingCycleCount == billingCycleCount && + other.billingPeriod == billingPeriod && + other.formattedPrice == formattedPrice && + other.priceAmountMicros == priceAmountMicros && + other.priceCurrencyCode == priceCurrencyCode && + other.recurrenceMode == recurrenceMode; + } + + @override + int get hashCode => Object.hash( + billingCycleCount, + billingPeriod, + formattedPrice, + priceAmountMicros, + priceCurrencyCode, + recurrenceMode, + ); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart new file mode 100644 index 000000000000..f6454a37b62c --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'subscription_offer_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SubscriptionOfferDetailsWrapper _$SubscriptionOfferDetailsWrapperFromJson( + Map json) => + SubscriptionOfferDetailsWrapper( + basePlanId: json['basePlanId'] as String? ?? '', + offerId: json['offerId'] as String?, + offerTags: (json['offerTags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + offerToken: json['offerToken'] as String? ?? '', + pricingPhases: (json['pricingPhases'] as List?) + ?.map((e) => + PricingPhase.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); + +PricingPhase _$PricingPhaseFromJson(Map json) => PricingPhase( + billingCycleCount: json['billingCycleCount'] as int? ?? 0, + billingPeriod: json['billingPeriod'] as String? ?? '', + formattedPrice: json['formattedPrice'] as String? ?? '', + priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, + priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + recurrenceMode: json['recurrenceMode'] == null + ? RecurrenceMode.nonRecurring + : const RecurrenceModeConverter() + .fromJson(json['recurrenceMode'] as int?), + ); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index b605c2f611c6..ab1979d8744f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -66,44 +66,53 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { .runWithClientNonRetryable((BillingClient client) => client.isReady()); } + /// Performs a network query for the details of products available. @override Future queryProductDetails( - Set identifiers) async { - List responses; + Set identifiers, + ) async { + List? productResponses; PlatformException? exception; - Future querySkuDetails(SkuType type) { - return billingClientManager.runWithClient( - (BillingClient client) => client.querySkuDetails( - skuType: type, - skusList: identifiers.toList(), - ), - ); - } - try { - responses = await Future.wait(>[ - querySkuDetails(SkuType.inapp), - querySkuDetails(SkuType.subs), - ]); + productResponses = await Future.wait( + >[ + billingClientManager.runWithClient( + (BillingClient client) => client.queryProductDetails( + productList: identifiers + .map((String productId) => + Product(id: productId, type: ProductType.subs)) + .toList(), + ), + ), + billingClientManager.runWithClient( + (BillingClient client) => client.queryProductDetails( + productList: identifiers + .map((String productId) => + Product(id: productId, type: ProductType.inapp)) + .toList(), + ), + ), + ], + ); } on PlatformException catch (e) { exception = e; - // ignore: invalid_use_of_visible_for_testing_member - final SkuDetailsResponseWrapper response = SkuDetailsResponseWrapper( - billingResult: BillingResultWrapper( - responseCode: BillingResponse.error, - debugMessage: e.code, - ), - skuDetailsList: const [], - ); - // Error response for both queries should be the same, so we can reuse it. - responses = [response, response]; + productResponses = [ + ProductDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + productDetailsList: const []), + ProductDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + productDetailsList: const []) + ]; } final List productDetailsList = - responses.expand((SkuDetailsResponseWrapper response) { - return response.skuDetailsList; - }).map((SkuDetailsWrapper skuDetailWrapper) { - return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); + productResponses.expand((ProductDetailsResponseWrapper response) { + return response.productDetailsList; + }).expand((ProductDetailsWrapper productDetailWrapper) { + return GooglePlayProductDetails.fromProductDetails(productDetailWrapper); }).toList(); final Set successIDS = productDetailsList @@ -131,17 +140,40 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { changeSubscriptionParam = purchaseParam.changeSubscriptionParam; } - final BillingResultWrapper billingResultWrapper = - await billingClientManager.runWithClient( - (BillingClient client) => client.launchBillingFlow( - sku: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName, - oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: changeSubscriptionParam - ?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: changeSubscriptionParam?.prorationMode, - ), + String? offerToken; + if (purchaseParam.productDetails is GooglePlayProductDetails) { + offerToken = + (purchaseParam.productDetails as GooglePlayProductDetails).offerToken; + } + + final bool isSupported = + await billingClientManager.runWithClientNonRetryable( + (BillingClient client) => + client.isFeatureSupported(BillingClientFeature.productDetails), ); + final BillingResultWrapper billingResultWrapper; + if (isSupported) { + billingResultWrapper = await billingClientManager.runWithClient( + (BillingClient client) => client.launchBillingFlow( + product: purchaseParam.productDetails.id, + offerToken: offerToken, + accountId: purchaseParam.applicationUserName, + oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode), + ); + } else { + billingResultWrapper = await billingClientManager.runWithClient( + (BillingClient client) => client.launchBillingFlow( + product: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName, + oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode), + ); + } return billingResultWrapper.responseCode == BillingResponse.ok; } @@ -188,10 +220,10 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { responses = await Future.wait(>[ billingClientManager.runWithClient( - (BillingClient client) => client.queryPurchases(SkuType.inapp), + (BillingClient client) => client.queryPurchases(ProductType.inapp), ), billingClientManager.runWithClient( - (BillingClient client) => client.queryPurchases(SkuType.subs), + (BillingClient client) => client.queryPurchases(ProductType.subs), ), ]); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index 67e21dfad8f8..bc0c6174f391 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -78,13 +78,14 @@ class InAppPurchaseAndroidPlatformAddition {String? applicationUserName}) async { List responses; PlatformException? exception; + try { responses = await Future.wait(>[ _billingClientManager.runWithClient( - (BillingClient client) => client.queryPurchases(SkuType.inapp), + (BillingClient client) => client.queryPurchases(ProductType.inapp), ), _billingClientManager.runWithClient( - (BillingClient client) => client.queryPurchases(SkuType.subs), + (BillingClient client) => client.queryPurchases(ProductType.subs), ), ]); } on PlatformException catch (e) { @@ -151,19 +152,4 @@ class InAppPurchaseAndroidPlatformAddition (BillingClient client) => client.isFeatureSupported(feature), ); } - - /// Initiates a flow to confirm the change of price for an item subscribed by the user. - /// - /// When the price of a user subscribed item has changed, launch this flow to take users to - /// a screen with price change information. User can confirm the new price or cancel the flow. - /// - /// The skuDetails needs to have already been fetched in a - /// [InAppPurchaseAndroidPlatform.queryProductDetails] call. - Future launchPriceChangeConfirmationFlow( - {required String sku}) { - return _billingClientManager.runWithClient( - (BillingClient client) => - client.launchPriceChangeConfirmationFlow(sku: sku), - ); - } } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 66dbf61236cb..791e9e4c63a0 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -18,28 +18,117 @@ class GooglePlayProductDetails extends ProductDetails { required super.price, required super.rawPrice, required super.currencyCode, - required this.skuDetails, + required this.productDetails, required super.currencySymbol, + this.subscriptionIndex, }); /// Generate a [GooglePlayProductDetails] object based on an Android - /// [SkuDetailsWrapper] object. - factory GooglePlayProductDetails.fromSkuDetails( - SkuDetailsWrapper skuDetails, + /// [ProductDetailsWrapper] object for an in-app product. + factory GooglePlayProductDetails._fromOneTimePurchaseProductDetails( + ProductDetailsWrapper productDetails, ) { + assert(productDetails.productType == ProductType.inapp); + assert(productDetails.oneTimePurchaseOfferDetails != null); + + final OneTimePurchaseOfferDetailsWrapper oneTimePurchaseOfferDetails = + productDetails.oneTimePurchaseOfferDetails!; + + final String formattedPrice = oneTimePurchaseOfferDetails.formattedPrice; + final double rawPrice = + oneTimePurchaseOfferDetails.priceAmountMicros / 1000000.0; + final String currencyCode = oneTimePurchaseOfferDetails.priceCurrencyCode; + final String currencySymbol = + formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; + + return GooglePlayProductDetails( + id: productDetails.productId, + title: productDetails.title, + description: productDetails.description, + price: formattedPrice, + rawPrice: rawPrice, + currencyCode: currencyCode, + currencySymbol: currencySymbol, + productDetails: productDetails, + ); + } + + /// Generate a [GooglePlayProductDetails] object based on an Android + /// [ProductDetailsWrapper] object for a subscription product. + factory GooglePlayProductDetails._fromSubscription( + ProductDetailsWrapper productDetails, + int subscriptionIndex, + ) { + assert(productDetails.productType == ProductType.subs); + assert(productDetails.subscriptionOfferDetails != null); + assert(subscriptionIndex < productDetails.subscriptionOfferDetails!.length); + + final SubscriptionOfferDetailsWrapper subscriptionOfferDetails = + productDetails.subscriptionOfferDetails![subscriptionIndex]; + + final PricingPhase firstPricingPhase = + subscriptionOfferDetails.pricingPhases.first; + final String formattedPrice = firstPricingPhase.formattedPrice; + final double rawPrice = (firstPricingPhase.priceAmountMicros) / 1000000.0; + final String currencyCode = firstPricingPhase.priceCurrencyCode; + final String currencySymbol = + formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; + return GooglePlayProductDetails( - id: skuDetails.sku, - title: skuDetails.title, - description: skuDetails.description, - price: skuDetails.price, - rawPrice: skuDetails.priceAmountMicros / 1000000.0, - currencyCode: skuDetails.priceCurrencyCode, - currencySymbol: skuDetails.priceCurrencySymbol, - skuDetails: skuDetails, + id: productDetails.productId, + title: productDetails.title, + description: productDetails.description, + price: formattedPrice, + rawPrice: rawPrice, + currencyCode: currencyCode, + currencySymbol: currencySymbol, + productDetails: productDetails, + subscriptionIndex: subscriptionIndex, ); } - /// Points back to the [SkuDetailsWrapper] object that was used to generate - /// this [GooglePlayProductDetails] object. - final SkuDetailsWrapper skuDetails; + /// Generate a list of [GooglePlayProductDetails] based on an Android + /// [ProductDetailsWrapper] object for a subscription product. + /// + /// Subscriptions can consist of multiple base plans, and base plans in turn + /// can consist of multiple offers. This method generates a list where every + /// element corresponds to a base plan or its offer. + static List fromProductDetails( + ProductDetailsWrapper productDetails, + ) { + if (productDetails.productType == ProductType.inapp) { + return [ + GooglePlayProductDetails._fromOneTimePurchaseProductDetails( + productDetails), + ]; + } else { + final List productDetailList = + []; + for (int subscriptionIndex = 0; + subscriptionIndex < productDetails.subscriptionOfferDetails!.length; + subscriptionIndex++) { + productDetailList.add(GooglePlayProductDetails._fromSubscription( + productDetails, + subscriptionIndex, + )); + } + + return productDetailList; + } + } + + /// Points back to the [ProductDetailsWrapper] object that was used to + /// generate this [GooglePlayProductDetails] object. + final ProductDetailsWrapper productDetails; + + /// The index pointing to the subscription this [GooglePlayProductDetails] + /// object was contructed for, or `null` if it was not a subscription. + final int? subscriptionIndex; + + /// The offerToken of the subscription this [GooglePlayProductDetails] + /// object was contructed for, or 'null' if it was not a subscription. + String? get offerToken => subscriptionIndex != null && + productDetails.subscriptionOfferDetails != null + ? productDetails.subscriptionOfferDetails![subscriptionIndex!].offerToken + : null; } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart index 9bf3fc5563bb..c82035d6007e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart @@ -26,7 +26,7 @@ class GooglePlayPurchaseDetails extends PurchaseDetails { factory GooglePlayPurchaseDetails.fromPurchase(PurchaseWrapper purchase) { final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails( purchaseID: purchase.orderId, - productID: purchase.sku, + productID: purchase.products.isNotEmpty ? purchase.products.first : '', verificationData: PurchaseVerificationData( localVerificationData: purchase.originalJson, serverVerificationData: purchase.purchaseToken, diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/product.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/product.dart new file mode 100644 index 000000000000..f6b5b66704fa --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/product.dart @@ -0,0 +1,16 @@ +import '../../billing_client_wrappers.dart'; + +/// Tuple object containing a product's id and type. +class Product { + /// Creates a new [Product]. + const Product({ + required this.id, + required this.type, + }); + + /// The product identifier. + final String id; + + /// The product type. + final ProductType type; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart index 0a43425f6e94..d581348204db 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart @@ -6,4 +6,5 @@ export 'change_subscription_param.dart'; export 'google_play_product_details.dart'; export 'google_play_purchase_details.dart'; export 'google_play_purchase_param.dart'; +export 'product.dart'; export 'query_purchase_details_response.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index f1aa0e10a7ee..54f686c61b23 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: flutter: sdk: flutter in_app_purchase_platform_interface: ^1.3.0 - json_annotation: ^4.6.0 + json_annotation: ^4.8.0 dev_dependencies: build_runner: ^2.0.0 diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 98219dc9d4e5..a886c70af38b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -194,7 +194,7 @@ void main() { const String profileId = 'hashedProfileId'; expect( - await billingClient.launchBillingFlow( + await billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId), @@ -223,7 +223,7 @@ void main() { const String profileId = 'hashedProfileId'; expect( - billingClient.launchBillingFlow( + billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId, @@ -231,7 +231,7 @@ void main() { throwsAssertionError); expect( - billingClient.launchBillingFlow( + billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId, @@ -255,7 +255,7 @@ void main() { const String profileId = 'hashedProfileId'; expect( - await billingClient.launchBillingFlow( + await billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId, @@ -291,7 +291,7 @@ void main() { ProrationMode.immediateAndChargeProratedPrice; expect( - await billingClient.launchBillingFlow( + await billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId, @@ -330,7 +330,7 @@ void main() { ProrationMode.immediateAndChargeFullPrice; expect( - await billingClient.launchBillingFlow( + await billingClient.launchBillingFlowV4( sku: skuDetails.sku, accountId: accountId, obfuscatedProfileId: profileId, @@ -362,7 +362,7 @@ void main() { ); const SkuDetailsWrapper skuDetails = dummySkuDetails; - expect(await billingClient.launchBillingFlow(sku: skuDetails.sku), + expect(await billingClient.launchBillingFlowV4(sku: skuDetails.sku), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) @@ -377,7 +377,7 @@ void main() { ); const SkuDetailsWrapper skuDetails = dummySkuDetails; expect( - await billingClient.launchBillingFlow(sku: skuDetails.sku), + await billingClient.launchBillingFlowV4(sku: skuDetails.sku), equals(const BillingResultWrapper( responseCode: BillingResponse.error, debugMessage: kInvalidBillingResultErrorMessage))); @@ -406,7 +406,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchasesV4(SkuType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -426,7 +426,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchasesV4(SkuType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -438,7 +438,7 @@ void main() { name: queryPurchasesMethodName, ); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchasesV4(SkuType.inapp); expect( response.billingResult, @@ -474,7 +474,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistoryV4(SkuType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, equals(expectedList)); }); @@ -492,7 +492,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistoryV4(SkuType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, isEmpty); @@ -503,7 +503,7 @@ void main() { name: queryPurchaseHistoryMethodName, ); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistoryV4(SkuType.inapp); expect( response.billingResult, diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart index 2d1436885427..bc16fa160101 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -68,8 +68,8 @@ void main() { test('toProductDetails() should return correct Product object', () { final SkuDetailsWrapper wrapper = SkuDetailsWrapper.fromJson(buildSkuMap(dummySkuDetails)); - final GooglePlayProductDetails product = - GooglePlayProductDetails.fromSkuDetails(wrapper); + final GooglePlayProductDetailsV4 product = + GooglePlayProductDetailsV4.fromSkuDetails(wrapper); expect(product.title, wrapper.title); expect(product.description, wrapper.description); expect(product.id, wrapper.sku); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index a679def27d51..bc57b2f46fef 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -370,7 +370,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyNonConsumable( purchaseParam: purchaseParam); @@ -414,7 +414,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); final PurchaseDetails result = await completer.future; @@ -487,7 +487,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -516,7 +516,7 @@ void main() { final bool result = await iapAndroidPlatform.buyNonConsumable( purchaseParam: GooglePlayPurchaseParam( productDetails: - GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + GooglePlayProductDetailsV4.fromSkuDetails(dummySkuDetails))); // Verify that the failure has been converted and returned expect(result, isFalse); @@ -536,7 +536,7 @@ void main() { final bool result = await iapAndroidPlatform.buyConsumable( purchaseParam: GooglePlayPurchaseParam( productDetails: - GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + GooglePlayProductDetailsV4.fromSkuDetails(dummySkuDetails))); // Verify that the failure has been converted and returned expect(result, isFalse); @@ -602,7 +602,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -677,7 +677,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); await iapAndroidPlatform.buyConsumable( purchaseParam: purchaseParam, autoConsume: false); @@ -746,7 +746,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -790,7 +790,7 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), applicationUserName: accountId, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: GooglePlayPurchaseDetails.fromPurchase( From 0751bf5922264c3cb58a13116804191a6dbb6195 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Fri, 14 Apr 2023 15:53:18 +0200 Subject: [PATCH 02/17] Update dart tests --- .../example/lib/main.dart | 20 +- .../product_details_wrapper.dart | 2 +- .../subscription_offer_details_wrapper.dart | 20 +- .../subscription_offer_details_wrapper.g.dart | 7 +- .../types/google_play_product_details.dart | 2 +- .../billing_client_wrapper_test.dart | 174 ++++------ .../product_details_wrapper_test.dart | 315 ++++++++++++++++++ .../purchase_wrapper_test.dart | 16 +- .../sku_details_wrapper_deprecated_test.dart | 69 ---- .../sku_details_wrapper_test.dart | 204 ------------ ...rchase_android_platform_addition_test.dart | 45 +-- ...in_app_purchase_android_platform_test.dart | 101 +++--- 12 files changed, 476 insertions(+), 499 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart delete mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart delete mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart index e27e1866835c..7039d1b123cc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/lib/main.dart @@ -254,21 +254,7 @@ class _MyAppState extends State<_MyApp> { productDetails.description, ), trailing: previousPurchase != null - ? IconButton( - onPressed: () { - final InAppPurchaseAndroidPlatformAddition addition = - InAppPurchasePlatformAddition.instance! - as InAppPurchaseAndroidPlatformAddition; - final SkuDetailsWrapper skuDetails = - (productDetails as GooglePlayProductDetailsV4) - .skuDetails; - addition - .launchPriceChangeConfirmationFlow( - sku: skuDetails.sku) - .then((BillingResultWrapper value) => print( - 'confirmationResponse: ${value.responseCode}')); - }, - icon: const Icon(Icons.upgrade)) + ? const SizedBox.shrink() : TextButton( style: TextButton.styleFrom( backgroundColor: Colors.green[800], @@ -283,7 +269,7 @@ class _MyAppState extends State<_MyApp> { // inside the app may not be accurate. final GooglePlayPurchaseDetails? oldSubscription = _getOldSubscription( - productDetails as GooglePlayProductDetailsV4, + productDetails as GooglePlayProductDetails, purchases); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( @@ -434,7 +420,7 @@ class _MyAppState extends State<_MyApp> { } GooglePlayPurchaseDetails? _getOldSubscription( - GooglePlayProductDetailsV4 productDetails, + GooglePlayProductDetails productDetails, Map purchases) { // This is just to demonstrate a subscription upgrade or downgrade. // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart index 1ebdf263d668..2a7c279b3fca 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart @@ -89,7 +89,7 @@ class ProductDetailsWrapper { other.oneTimePurchaseOfferDetails == oneTimePurchaseOfferDetails && other.productId == productId && other.productType == productType && - other.subscriptionOfferDetails == subscriptionOfferDetails && + listEquals(other.subscriptionOfferDetails, subscriptionOfferDetails) && other.title == title; } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart index 1a3c41181682..8e079f96c12f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.dart @@ -55,8 +55,8 @@ class SubscriptionOfferDetailsWrapper { final String offerToken; /// The pricing phases for the subscription product. - @JsonKey(defaultValue: []) - final List pricingPhases; + @JsonKey(defaultValue: []) + final List pricingPhases; @override bool operator ==(Object other) { @@ -69,7 +69,7 @@ class SubscriptionOfferDetailsWrapper { other.offerId == offerId && listEquals(other.offerTags, offerTags) && other.offerToken == offerToken && - other.pricingPhases == pricingPhases; + listEquals(other.pricingPhases, pricingPhases); } @override @@ -88,10 +88,10 @@ class SubscriptionOfferDetailsWrapper { @JsonSerializable() @RecurrenceModeConverter() @immutable -class PricingPhase { - /// Creates a new [PricingPhase] from the supplied info. +class PricingPhaseWrapper { + /// Creates a new [PricingPhaseWrapper] from the supplied info. @visibleForTesting - const PricingPhase({ + const PricingPhaseWrapper({ required this.billingCycleCount, required this.billingPeriod, required this.formattedPrice, @@ -100,9 +100,9 @@ class PricingPhase { required this.recurrenceMode, }); - /// Factory for creating a [PricingPhase] from a [Map] with the phase details. - factory PricingPhase.fromJson(Map map) => - _$PricingPhaseFromJson(map); + /// Factory for creating a [PricingPhaseWrapper] from a [Map] with the phase details. + factory PricingPhaseWrapper.fromJson(Map map) => + _$PricingPhaseWrapperFromJson(map); /// Represents a pricing phase, describing how a user pays at a point in time. @JsonKey(defaultValue: 0) @@ -137,7 +137,7 @@ class PricingPhase { return false; } - return other is PricingPhase && + return other is PricingPhaseWrapper && other.billingCycleCount == billingCycleCount && other.billingPeriod == billingPeriod && other.formattedPrice == formattedPrice && diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart index f6454a37b62c..fb392e6251c3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart @@ -17,13 +17,14 @@ SubscriptionOfferDetailsWrapper _$SubscriptionOfferDetailsWrapperFromJson( [], offerToken: json['offerToken'] as String? ?? '', pricingPhases: (json['pricingPhases'] as List?) - ?.map((e) => - PricingPhase.fromJson(Map.from(e as Map))) + ?.map((e) => PricingPhaseWrapper.fromJson( + Map.from(e as Map))) .toList() ?? [], ); -PricingPhase _$PricingPhaseFromJson(Map json) => PricingPhase( +PricingPhaseWrapper _$PricingPhaseWrapperFromJson(Map json) => + PricingPhaseWrapper( billingCycleCount: json['billingCycleCount'] as int? ?? 0, billingPeriod: json['billingPeriod'] as String? ?? '', formattedPrice: json['formattedPrice'] as String? ?? '', diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 791e9e4c63a0..1dd36224d0e0 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -66,7 +66,7 @@ class GooglePlayProductDetails extends ProductDetails { final SubscriptionOfferDetailsWrapper subscriptionOfferDetails = productDetails.subscriptionOfferDetails![subscriptionIndex]; - final PricingPhase firstPricingPhase = + final PricingPhaseWrapper firstPricingPhase = subscriptionOfferDetails.pricingPhases.first; final String formattedPrice = firstPricingPhase.formattedPrice; final double rawPrice = (firstPricingPhase.priceAmountMicros) / 1000000.0; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index a886c70af38b..6a82324735ff 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -6,10 +6,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/types/product.dart'; import '../stub_in_app_purchase_platform.dart'; +import 'product_details_wrapper_test.dart'; import 'purchase_wrapper_test.dart'; -import 'sku_details_wrapper_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -115,11 +116,11 @@ void main() { expect(stubPlatform.countPreviousCalls(endConnectionName), equals(1)); }); - group('querySkuDetails', () { + group('queryProductDetails', () { const String queryMethodName = - 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + 'BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)'; - test('handles empty skuDetails', () async { + test('handles empty productDetails', () async { const String debugMessage = 'dummy message'; const BillingResponse responseCode = BillingResponse.developerError; stubPlatform.addResponse(name: queryMethodName, value: { @@ -127,20 +128,21 @@ void main() { 'responseCode': const BillingResponseConverter().toJson(responseCode), 'debugMessage': debugMessage, }, - 'skuDetailsList': >[] + 'productDetailsList': >[] }); - final SkuDetailsResponseWrapper response = await billingClient - .querySkuDetails( - skuType: SkuType.inapp, skusList: ['invalid']); + final ProductDetailsResponseWrapper response = await billingClient + .queryProductDetails(productList: [ + const Product(id: 'invalid', type: ProductType.inapp) + ]); const BillingResultWrapper billingResult = BillingResultWrapper( responseCode: responseCode, debugMessage: debugMessage); expect(response.billingResult, equals(billingResult)); - expect(response.skuDetailsList, isEmpty); + expect(response.productDetailsList, isEmpty); }); - test('returns SkuDetailsResponseWrapper', () async { + test('returns ProductDetailsResponseWrapper', () async { const String debugMessage = 'dummy message'; const BillingResponse responseCode = BillingResponse.ok; stubPlatform.addResponse(name: queryMethodName, value: { @@ -148,31 +150,39 @@ void main() { 'responseCode': const BillingResponseConverter().toJson(responseCode), 'debugMessage': debugMessage, }, - 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails) + ], }); - final SkuDetailsResponseWrapper response = await billingClient - .querySkuDetails( - skuType: SkuType.inapp, skusList: ['invalid']); + final ProductDetailsResponseWrapper response = + await billingClient.queryProductDetails( + productList: [ + const Product(id: 'invalid', type: ProductType.inapp), + ], + ); const BillingResultWrapper billingResult = BillingResultWrapper( responseCode: responseCode, debugMessage: debugMessage); expect(response.billingResult, equals(billingResult)); - expect(response.skuDetailsList, contains(dummySkuDetails)); + expect(response.productDetailsList, contains(dummyOneTimeProductDetails)); }); test('handles null method channel response', () async { stubPlatform.addResponse(name: queryMethodName); - final SkuDetailsResponseWrapper response = await billingClient - .querySkuDetails( - skuType: SkuType.inapp, skusList: ['invalid']); + final ProductDetailsResponseWrapper response = + await billingClient.queryProductDetails( + productList: [ + const Product(id: 'invalid', type: ProductType.inapp), + ], + ); const BillingResultWrapper billingResult = BillingResultWrapper( responseCode: BillingResponse.error, debugMessage: kInvalidBillingResultErrorMessage); expect(response.billingResult, equals(billingResult)); - expect(response.skuDetailsList, isEmpty); + expect(response.productDetailsList, isEmpty); }); }); @@ -189,26 +199,26 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String profileId = 'hashedProfileId'; expect( - await billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + await billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) .arguments as Map; - expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['product'], equals(productDetails.productId)); expect(arguments['accountId'], equals(accountId)); expect(arguments['obfuscatedProfileId'], equals(profileId)); }); test( - 'Change subscription throws assertion error `oldSku` and `purchaseToken` has different nullability', + 'Change subscription throws assertion error `oldProduct` and `purchaseToken` has different nullability', () async { const String debugMessage = 'dummy message'; const BillingResponse responseCode = BillingResponse.ok; @@ -218,21 +228,21 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String profileId = 'hashedProfileId'; expect( - billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, - oldSku: dummyOldPurchase.sku), + oldProduct: dummyOldPurchase.products.first), throwsAssertionError); expect( - billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, purchaseToken: dummyOldPurchase.purchaseToken), @@ -250,24 +260,24 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String profileId = 'hashedProfileId'; expect( - await billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + await billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, - oldSku: dummyOldPurchase.sku, + oldProduct: dummyOldPurchase.products.first, purchaseToken: dummyOldPurchase.purchaseToken), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) .arguments as Map; - expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['product'], equals(productDetails.productId)); expect(arguments['accountId'], equals(accountId)); - expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['oldProduct'], equals(dummyOldPurchase.products.first)); expect( arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); expect(arguments['obfuscatedProfileId'], equals(profileId)); @@ -284,27 +294,27 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String profileId = 'hashedProfileId'; const ProrationMode prorationMode = ProrationMode.immediateAndChargeProratedPrice; expect( - await billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + await billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, - oldSku: dummyOldPurchase.sku, + oldProduct: dummyOldPurchase.products.first, prorationMode: prorationMode, purchaseToken: dummyOldPurchase.purchaseToken), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) .arguments as Map; - expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['product'], equals(productDetails.productId)); expect(arguments['accountId'], equals(accountId)); - expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['oldProduct'], equals(dummyOldPurchase.products.first)); expect(arguments['obfuscatedProfileId'], equals(profileId)); expect( arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); @@ -323,27 +333,27 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String profileId = 'hashedProfileId'; const ProrationMode prorationMode = ProrationMode.immediateAndChargeFullPrice; expect( - await billingClient.launchBillingFlowV4( - sku: skuDetails.sku, + await billingClient.launchBillingFlow( + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, - oldSku: dummyOldPurchase.sku, + oldProduct: dummyOldPurchase.products.first, prorationMode: prorationMode, purchaseToken: dummyOldPurchase.purchaseToken), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) .arguments as Map; - expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['product'], equals(productDetails.productId)); expect(arguments['accountId'], equals(accountId)); - expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['oldProduct'], equals(dummyOldPurchase.products.first)); expect(arguments['obfuscatedProfileId'], equals(profileId)); expect( arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); @@ -360,14 +370,16 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; - expect(await billingClient.launchBillingFlowV4(sku: skuDetails.sku), + expect( + await billingClient.launchBillingFlow( + product: productDetails.productId), equals(expectedBillingResult)); final Map arguments = stubPlatform .previousCallMatching(launchMethodName) .arguments as Map; - expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['product'], equals(productDetails.productId)); expect(arguments['accountId'], isNull); }); @@ -375,9 +387,10 @@ void main() { stubPlatform.addResponse( name: launchMethodName, ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; expect( - await billingClient.launchBillingFlowV4(sku: skuDetails.sku), + await billingClient.launchBillingFlow( + product: productDetails.productId), equals(const BillingResultWrapper( responseCode: BillingResponse.error, debugMessage: kInvalidBillingResultErrorMessage))); @@ -406,7 +419,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchasesV4(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -426,7 +439,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchasesV4(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -438,7 +451,7 @@ void main() { name: queryPurchasesMethodName, ); final PurchasesResultWrapper response = - await billingClient.queryPurchasesV4(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect( response.billingResult, @@ -452,7 +465,7 @@ void main() { group('queryPurchaseHistory', () { const String queryPurchaseHistoryMethodName = - 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)'; + 'BillingClient#queryPurchaseHistoryAsync(String)'; test('serializes and deserializes data', () async { const BillingResponse expectedCode = BillingResponse.ok; @@ -474,7 +487,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistoryV4(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, equals(expectedList)); }); @@ -492,7 +505,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistoryV4(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, isEmpty); @@ -503,7 +516,7 @@ void main() { name: queryPurchaseHistoryMethodName, ); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistoryV4(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect( response.billingResult, @@ -610,47 +623,6 @@ void main() { expect(arguments['feature'], equals('subscriptions')); }); }); - - group('launchPriceChangeConfirmationFlow', () { - const String launchPriceChangeConfirmationFlowMethodName = - 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)'; - - const BillingResultWrapper expectedBillingResultPriceChangeConfirmation = - BillingResultWrapper( - responseCode: BillingResponse.ok, - debugMessage: 'dummy message', - ); - - test('serializes and deserializes data', () async { - stubPlatform.addResponse( - name: launchPriceChangeConfirmationFlowMethodName, - value: - buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), - ); - - expect( - await billingClient.launchPriceChangeConfirmationFlow( - sku: dummySkuDetails.sku, - ), - equals(expectedBillingResultPriceChangeConfirmation), - ); - }); - - test('passes sku to launchPriceChangeConfirmationFlow', () async { - stubPlatform.addResponse( - name: launchPriceChangeConfirmationFlowMethodName, - value: - buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), - ); - await billingClient.launchPriceChangeConfirmationFlow( - sku: dummySkuDetails.sku, - ); - final MethodCall call = stubPlatform - .previousCallMatching(launchPriceChangeConfirmationFlowMethodName); - expect(call.arguments, - equals({'sku': dummySkuDetails.sku})); - }); - }); } /// This allows a value of type T or T? to be treated as a value of type T?. diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart new file mode 100644 index 000000000000..da65f9c92a04 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart @@ -0,0 +1,315 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/types/google_play_product_details.dart'; +import 'package:test/test.dart'; + +const ProductDetailsWrapper dummyOneTimeProductDetails = ProductDetailsWrapper( + description: 'description', + name: 'name', + productId: 'productId', + productType: ProductType.inapp, + title: 'title', + oneTimePurchaseOfferDetails: OneTimePurchaseOfferDetailsWrapper( + formattedPrice: r'$100', + priceAmountMicros: 100000000, + priceCurrencyCode: 'USD', + ), +); + +const ProductDetailsWrapper dummySubscriptionProductDetails = + ProductDetailsWrapper( + description: 'description', + name: 'name', + productId: 'productId', + productType: ProductType.subs, + title: 'title', + subscriptionOfferDetails: [ + SubscriptionOfferDetailsWrapper( + basePlanId: 'basePlanId', + offerTags: ['offerTags'], + offerId: 'offerId', + offerToken: 'offerToken', + pricingPhases: [ + PricingPhaseWrapper( + billingCycleCount: 4, + billingPeriod: 'billingPeriod', + formattedPrice: r'$100', + priceAmountMicros: 100000000, + priceCurrencyCode: 'USD', + recurrenceMode: RecurrenceMode.finiteRecurring, + ), + ], + ), + ], +); + +void main() { + group('ProductDetailsWrapper', () { + test('converts one-time purchase from map', () { + const ProductDetailsWrapper expected = dummyOneTimeProductDetails; + final ProductDetailsWrapper parsed = + ProductDetailsWrapper.fromJson(buildProductMap(expected)); + + expect(parsed, equals(expected)); + }); + + test('converts subscription from map', () { + const ProductDetailsWrapper expected = dummySubscriptionProductDetails; + final ProductDetailsWrapper parsed = + ProductDetailsWrapper.fromJson(buildProductMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + + group('ProductDetailsResponseWrapper', () { + test('parsed from map', () { + const BillingResponse responseCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final List productsDetails = + [ + dummyOneTimeProductDetails, + dummyOneTimeProductDetails, + ]; + const BillingResultWrapper result = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final ProductDetailsResponseWrapper expected = + ProductDetailsResponseWrapper( + billingResult: result, productDetailsList: productsDetails); + + final ProductDetailsResponseWrapper parsed = + ProductDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': const BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails), + buildProductMap(dummyOneTimeProductDetails), + ], + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect( + parsed.productDetailsList, containsAll(expected.productDetailsList)); + }); + + test('toProductDetails() should return correct Product object', () { + final ProductDetailsWrapper wrapper = ProductDetailsWrapper.fromJson( + buildProductMap(dummyOneTimeProductDetails)); + final GooglePlayProductDetails product = + GooglePlayProductDetails.fromProductDetails(wrapper).first; + expect(product.title, wrapper.title); + expect(product.description, wrapper.description); + expect(product.id, wrapper.productId); + expect( + product.price, wrapper.oneTimePurchaseOfferDetails?.formattedPrice); + expect(product.productDetails, wrapper); + }); + + test('handles empty list of productDetails', () { + const BillingResponse responseCode = BillingResponse.error; + const String debugMessage = 'dummy message'; + final List productsDetails = + []; + const BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final ProductDetailsResponseWrapper expected = + ProductDetailsResponseWrapper( + billingResult: billingResult, + productDetailsList: productsDetails); + + final ProductDetailsResponseWrapper parsed = + ProductDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': const BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'productDetailsList': const >[] + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect( + parsed.productDetailsList, containsAll(expected.productDetailsList)); + }); + + test('fromJson creates an object with default values', () { + final ProductDetailsResponseWrapper productDetails = + ProductDetailsResponseWrapper.fromJson(const {}); + expect( + productDetails.billingResult, + equals(const BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(productDetails.productDetailsList, isEmpty); + }); + }); + + group('BillingResultWrapper', () { + test('fromJson on empty map creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson(const {}); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + + test('fromJson on null creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson(null); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + + test('operator == of ProductDetailsWrapper works fine', () { + const ProductDetailsWrapper firstProductDetailsInstance = + ProductDetailsWrapper( + description: 'description', + title: 'title', + productType: ProductType.inapp, + name: 'name', + productId: 'productId', + oneTimePurchaseOfferDetails: OneTimePurchaseOfferDetailsWrapper( + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + ), + subscriptionOfferDetails: [ + SubscriptionOfferDetailsWrapper( + basePlanId: 'basePlanId', + offerTags: ['offerTags'], + offerToken: 'offerToken', + pricingPhases: [ + PricingPhaseWrapper( + billingCycleCount: 4, + billingPeriod: 'billingPeriod', + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + recurrenceMode: RecurrenceMode.finiteRecurring, + ), + ], + ), + ], + ); + const ProductDetailsWrapper secondProductDetailsInstance = + ProductDetailsWrapper( + description: 'description', + title: 'title', + productType: ProductType.inapp, + name: 'name', + productId: 'productId', + oneTimePurchaseOfferDetails: OneTimePurchaseOfferDetailsWrapper( + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + ), + subscriptionOfferDetails: [ + SubscriptionOfferDetailsWrapper( + basePlanId: 'basePlanId', + offerTags: ['offerTags'], + offerToken: 'offerToken', + pricingPhases: [ + PricingPhaseWrapper( + billingCycleCount: 4, + billingPeriod: 'billingPeriod', + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + recurrenceMode: RecurrenceMode.finiteRecurring, + ), + ], + ), + ], + ); + expect( + firstProductDetailsInstance == secondProductDetailsInstance, isTrue); + }); + + test('operator == of BillingResultWrapper works fine', () { + const BillingResultWrapper firstBillingResultInstance = + BillingResultWrapper( + responseCode: BillingResponse.ok, + debugMessage: 'debugMessage', + ); + const BillingResultWrapper secondBillingResultInstance = + BillingResultWrapper( + responseCode: BillingResponse.ok, + debugMessage: 'debugMessage', + ); + expect(firstBillingResultInstance == secondBillingResultInstance, isTrue); + }); + }); +} + +Map buildProductMap(ProductDetailsWrapper original) { + final Map map = { + 'title': original.title, + 'description': original.description, + 'productId': original.productId, + 'productType': const ProductTypeConverter().toJson(original.productType), + 'name': original.name, + }; + + if (original.oneTimePurchaseOfferDetails != null) { + map.putIfAbsent('oneTimePurchaseOfferDetails', + () => buildOneTimePurchaseMap(original.oneTimePurchaseOfferDetails!)); + } + + if (original.subscriptionOfferDetails != null) { + map.putIfAbsent('subscriptionOfferDetails', + () => buildSubscriptionMapList(original.subscriptionOfferDetails!)); + } + + return map; +} + +Map buildOneTimePurchaseMap( + OneTimePurchaseOfferDetailsWrapper original) { + return { + 'priceAmountMicros': original.priceAmountMicros, + 'priceCurrencyCode': original.priceCurrencyCode, + 'formattedPrice': original.formattedPrice, + }; +} + +List> buildSubscriptionMapList( + List original) { + return original + .map((SubscriptionOfferDetailsWrapper subscriptionOfferDetails) => + buildSubscriptionMap(subscriptionOfferDetails)) + .toList(); +} + +Map buildSubscriptionMap( + SubscriptionOfferDetailsWrapper original) { + return { + 'offerId': original.offerId, + 'basePlanId': original.basePlanId, + 'offerTags': original.offerTags, + 'offerToken': original.offerToken, + 'pricingPhases': buildPricingPhaseMapList(original.pricingPhases), + }; +} + +List> buildPricingPhaseMapList( + List original) { + return original + .map((PricingPhaseWrapper pricingPhase) => + buildPricingPhaseMap(pricingPhase)) + .toList(); +} + +Map buildPricingPhaseMap(PricingPhaseWrapper original) { + return { + 'formattedPrice': original.formattedPrice, + 'priceCurrencyCode': original.priceCurrencyCode, + 'priceAmountMicros': original.priceAmountMicros, + 'billingCycleCount': original.billingCycleCount, + 'billingPeriod': original.billingPeriod, + 'recurrenceMode': + const RecurrenceModeConverter().toJson(original.recurrenceMode), + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart index 184d9331e6c1..8da70aaab5f4 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -11,7 +11,7 @@ const PurchaseWrapper dummyPurchase = PurchaseWrapper( packageName: 'packageName', purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', isAutoRenewing: false, originalJson: '', @@ -27,7 +27,7 @@ const PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( packageName: 'packageName', purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', isAutoRenewing: false, originalJson: '', @@ -40,7 +40,7 @@ const PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = PurchaseHistoryRecordWrapper( purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', originalJson: '', developerPayload: 'dummy payload', @@ -51,7 +51,7 @@ const PurchaseWrapper dummyOldPurchase = PurchaseWrapper( packageName: 'oldPackageName', purchaseTime: 0, signature: 'oldSignature', - skus: ['oldSku'], + products: ['oldProduct'], purchaseToken: 'oldPurchaseToken', isAutoRenewing: false, originalJson: '', @@ -75,7 +75,7 @@ void main() { GooglePlayPurchaseDetails.fromPurchase(dummyPurchase); expect(details.purchaseID, dummyPurchase.orderId); - expect(details.productID, dummyPurchase.sku); + expect(details.productID, dummyPurchase.products.first); expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); expect(details.verificationData, isNotNull); expect(details.verificationData.source, kIAPSource); @@ -94,7 +94,7 @@ void main() { GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); expect(details.purchaseID, dummyPurchase.orderId); - expect(details.productID, dummyPurchase.sku); + expect(details.productID, dummyPurchase.products.first); expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); expect(details.verificationData, isNotNull); expect(details.verificationData.source, kIAPSource); @@ -205,7 +205,7 @@ Map buildPurchaseMap(PurchaseWrapper original) { 'packageName': original.packageName, 'purchaseTime': original.purchaseTime, 'signature': original.signature, - 'skus': original.skus, + 'products': original.products, 'purchaseToken': original.purchaseToken, 'isAutoRenewing': original.isAutoRenewing, 'originalJson': original.originalJson, @@ -223,7 +223,7 @@ Map buildPurchaseHistoryRecordMap( return { 'purchaseTime': original.purchaseTime, 'signature': original.signature, - 'skus': original.skus, + 'products': original.products, 'purchaseToken': original.purchaseToken, 'originalJson': original.originalJson, 'developerPayload': original.developerPayload, diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart deleted file mode 100644 index f27ea02209c4..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// TODO(mvanbeusekom): Remove this file when the deprecated -// `SkuDetailsWrapper.introductoryPriceMicros` field is -// removed. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:in_app_purchase_android/billing_client_wrappers.dart'; - -void main() { - test( - 'Deprecated `introductoryPriceMicros` field reflects parameter from constructor', - () { - const SkuDetailsWrapper skuDetails = SkuDetailsWrapper( - description: 'description', - freeTrialPeriod: 'freeTrialPeriod', - introductoryPrice: 'introductoryPrice', - // ignore: deprecated_member_use_from_same_package - introductoryPriceMicros: '990000', - introductoryPriceCycles: 1, - introductoryPricePeriod: 'introductoryPricePeriod', - price: 'price', - priceAmountMicros: 1000, - priceCurrencyCode: 'priceCurrencyCode', - priceCurrencySymbol: r'$', - sku: 'sku', - subscriptionPeriod: 'subscriptionPeriod', - title: 'title', - type: SkuType.inapp, - originalPrice: 'originalPrice', - originalPriceAmountMicros: 1000, - ); - - expect(skuDetails, isNotNull); - expect(skuDetails.introductoryPriceAmountMicros, 0); - // ignore: deprecated_member_use_from_same_package - expect(skuDetails.introductoryPriceMicros, '990000'); - }); - - test( - '`introductoryPriceAmoutMicros` constructor parameter is reflected by deprecated `introductoryPriceMicros` and `introductoryPriceAmountMicros` fields', - () { - const SkuDetailsWrapper skuDetails = SkuDetailsWrapper( - description: 'description', - freeTrialPeriod: 'freeTrialPeriod', - introductoryPrice: 'introductoryPrice', - introductoryPriceAmountMicros: 990000, - introductoryPriceCycles: 1, - introductoryPricePeriod: 'introductoryPricePeriod', - price: 'price', - priceAmountMicros: 1000, - priceCurrencyCode: 'priceCurrencyCode', - priceCurrencySymbol: r'$', - sku: 'sku', - subscriptionPeriod: 'subscriptionPeriod', - title: 'title', - type: SkuType.inapp, - originalPrice: 'originalPrice', - originalPriceAmountMicros: 1000, - ); - - expect(skuDetails, isNotNull); - expect(skuDetails.introductoryPriceAmountMicros, 990000); - // ignore: deprecated_member_use_from_same_package - expect(skuDetails.introductoryPriceMicros, '990000'); - }); -} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart deleted file mode 100644 index bc16fa160101..000000000000 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ /dev/null @@ -1,204 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:in_app_purchase_android/billing_client_wrappers.dart'; -import 'package:in_app_purchase_android/src/types/google_play_product_details.dart'; -import 'package:test/test.dart'; - -const SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( - description: 'description', - freeTrialPeriod: 'freeTrialPeriod', - introductoryPrice: 'introductoryPrice', - introductoryPriceAmountMicros: 990000, - introductoryPriceCycles: 1, - introductoryPricePeriod: 'introductoryPricePeriod', - price: 'price', - priceAmountMicros: 1000, - priceCurrencyCode: 'priceCurrencyCode', - priceCurrencySymbol: r'$', - sku: 'sku', - subscriptionPeriod: 'subscriptionPeriod', - title: 'title', - type: SkuType.inapp, - originalPrice: 'originalPrice', - originalPriceAmountMicros: 1000, -); - -void main() { - group('SkuDetailsWrapper', () { - test('converts from map', () { - const SkuDetailsWrapper expected = dummySkuDetails; - final SkuDetailsWrapper parsed = - SkuDetailsWrapper.fromJson(buildSkuMap(expected)); - - expect(parsed, equals(expected)); - }); - }); - - group('SkuDetailsResponseWrapper', () { - test('parsed from map', () { - const BillingResponse responseCode = BillingResponse.ok; - const String debugMessage = 'dummy message'; - final List skusDetails = [ - dummySkuDetails, - dummySkuDetails - ]; - const BillingResultWrapper result = BillingResultWrapper( - responseCode: responseCode, debugMessage: debugMessage); - final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( - billingResult: result, skuDetailsList: skusDetails); - - final SkuDetailsResponseWrapper parsed = - SkuDetailsResponseWrapper.fromJson({ - 'billingResult': { - 'responseCode': const BillingResponseConverter().toJson(responseCode), - 'debugMessage': debugMessage, - }, - 'skuDetailsList': >[ - buildSkuMap(dummySkuDetails), - buildSkuMap(dummySkuDetails) - ] - }); - - expect(parsed.billingResult, equals(expected.billingResult)); - expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); - }); - - test('toProductDetails() should return correct Product object', () { - final SkuDetailsWrapper wrapper = - SkuDetailsWrapper.fromJson(buildSkuMap(dummySkuDetails)); - final GooglePlayProductDetailsV4 product = - GooglePlayProductDetailsV4.fromSkuDetails(wrapper); - expect(product.title, wrapper.title); - expect(product.description, wrapper.description); - expect(product.id, wrapper.sku); - expect(product.price, wrapper.price); - expect(product.skuDetails, wrapper); - }); - - test('handles empty list of skuDetails', () { - const BillingResponse responseCode = BillingResponse.error; - const String debugMessage = 'dummy message'; - final List skusDetails = []; - const BillingResultWrapper billingResult = BillingResultWrapper( - responseCode: responseCode, debugMessage: debugMessage); - final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( - billingResult: billingResult, skuDetailsList: skusDetails); - - final SkuDetailsResponseWrapper parsed = - SkuDetailsResponseWrapper.fromJson({ - 'billingResult': { - 'responseCode': const BillingResponseConverter().toJson(responseCode), - 'debugMessage': debugMessage, - }, - 'skuDetailsList': const >[] - }); - - expect(parsed.billingResult, equals(expected.billingResult)); - expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); - }); - - test('fromJson creates an object with default values', () { - final SkuDetailsResponseWrapper skuDetails = - SkuDetailsResponseWrapper.fromJson(const {}); - expect( - skuDetails.billingResult, - equals(const BillingResultWrapper( - responseCode: BillingResponse.error, - debugMessage: kInvalidBillingResultErrorMessage))); - expect(skuDetails.skuDetailsList, isEmpty); - }); - }); - - group('BillingResultWrapper', () { - test('fromJson on empty map creates an object with default values', () { - final BillingResultWrapper billingResult = - BillingResultWrapper.fromJson(const {}); - expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); - expect(billingResult.responseCode, BillingResponse.error); - }); - - test('fromJson on null creates an object with default values', () { - final BillingResultWrapper billingResult = - BillingResultWrapper.fromJson(null); - expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); - expect(billingResult.responseCode, BillingResponse.error); - }); - - test('operator == of SkuDetailsWrapper works fine', () { - const SkuDetailsWrapper firstSkuDetailsInstance = SkuDetailsWrapper( - description: 'description', - freeTrialPeriod: 'freeTrialPeriod', - introductoryPrice: 'introductoryPrice', - introductoryPriceAmountMicros: 990000, - introductoryPriceCycles: 1, - introductoryPricePeriod: 'introductoryPricePeriod', - price: 'price', - priceAmountMicros: 1000, - priceCurrencyCode: 'priceCurrencyCode', - priceCurrencySymbol: r'$', - sku: 'sku', - subscriptionPeriod: 'subscriptionPeriod', - title: 'title', - type: SkuType.inapp, - originalPrice: 'originalPrice', - originalPriceAmountMicros: 1000, - ); - const SkuDetailsWrapper secondSkuDetailsInstance = SkuDetailsWrapper( - description: 'description', - freeTrialPeriod: 'freeTrialPeriod', - introductoryPrice: 'introductoryPrice', - introductoryPriceAmountMicros: 990000, - introductoryPriceCycles: 1, - introductoryPricePeriod: 'introductoryPricePeriod', - price: 'price', - priceAmountMicros: 1000, - priceCurrencyCode: 'priceCurrencyCode', - priceCurrencySymbol: r'$', - sku: 'sku', - subscriptionPeriod: 'subscriptionPeriod', - title: 'title', - type: SkuType.inapp, - originalPrice: 'originalPrice', - originalPriceAmountMicros: 1000, - ); - expect(firstSkuDetailsInstance == secondSkuDetailsInstance, isTrue); - }); - - test('operator == of BillingResultWrapper works fine', () { - const BillingResultWrapper firstBillingResultInstance = - BillingResultWrapper( - responseCode: BillingResponse.ok, - debugMessage: 'debugMessage', - ); - const BillingResultWrapper secondBillingResultInstance = - BillingResultWrapper( - responseCode: BillingResponse.ok, - debugMessage: 'debugMessage', - ); - expect(firstBillingResultInstance == secondBillingResultInstance, isTrue); - }); - }); -} - -Map buildSkuMap(SkuDetailsWrapper original) { - return { - 'description': original.description, - 'freeTrialPeriod': original.freeTrialPeriod, - 'introductoryPrice': original.introductoryPrice, - 'introductoryPriceAmountMicros': original.introductoryPriceAmountMicros, - 'introductoryPriceCycles': original.introductoryPriceCycles, - 'introductoryPricePeriod': original.introductoryPricePeriod, - 'price': original.price, - 'priceAmountMicros': original.priceAmountMicros, - 'priceCurrencyCode': original.priceCurrencyCode, - 'priceCurrencySymbol': original.priceCurrencySymbol, - 'sku': original.sku, - 'subscriptionPeriod': original.subscriptionPeriod, - 'title': original.title, - 'type': original.type.toString().substring(8), - 'originalPrice': original.originalPrice, - 'originalPriceAmountMicros': original.originalPriceAmountMicros, - }; -} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 1b61f53b0d38..578a0d01cc55 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -86,7 +86,7 @@ void main() { expect(response.error!.source, kIAPSource); }); - test('returns SkuDetailsResponseWrapper', () async { + test('returns ProductDetailsResponseWrapper', () async { const String debugMessage = 'dummy message'; const BillingResponse responseCode = BillingResponse.ok; const BillingResultWrapper expectedBillingResult = BillingResultWrapper( @@ -101,7 +101,7 @@ void main() { ] }); - // Since queryPastPurchases makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // Since queryPastPurchases makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead // of 1. final QueryPurchaseDetailsResponse response = await iapAndroidPlatformAddition.queryPastPurchases(); @@ -173,47 +173,6 @@ void main() { expect(arguments['feature'], equals('subscriptions')); }); }); - - group('launchPriceChangeConfirmationFlow', () { - const String launchPriceChangeConfirmationFlowMethodName = - 'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)'; - const String dummySku = 'sku'; - - const BillingResultWrapper expectedBillingResultPriceChangeConfirmation = - BillingResultWrapper( - responseCode: BillingResponse.ok, - debugMessage: 'dummy message', - ); - - test('serializes and deserializes data', () async { - stubPlatform.addResponse( - name: launchPriceChangeConfirmationFlowMethodName, - value: - buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), - ); - - expect( - await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow( - sku: dummySku, - ), - equals(expectedBillingResultPriceChangeConfirmation), - ); - }); - - test('passes sku to launchPriceChangeConfirmationFlow', () async { - stubPlatform.addResponse( - name: launchPriceChangeConfirmationFlowMethodName, - value: - buildBillingResultMap(expectedBillingResultPriceChangeConfirmation), - ); - await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow( - sku: dummySku, - ); - final MethodCall call = stubPlatform - .previousCallMatching(launchPriceChangeConfirmationFlowMethodName); - expect(call.arguments, equals({'sku': dummySku})); - }); - }); } /// This allows a value of type T or T? to be treated as a value of type T?. diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index bc57b2f46fef..6349ca2a3610 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -12,8 +12,8 @@ import 'package:in_app_purchase_android/in_app_purchase_android.dart'; import 'package:in_app_purchase_android/src/channel.dart'; import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'billing_client_wrappers/product_details_wrapper_test.dart'; import 'billing_client_wrappers/purchase_wrapper_test.dart'; -import 'billing_client_wrappers/sku_details_wrapper_test.dart'; import 'stub_in_app_purchase_platform.dart'; void main() { @@ -114,18 +114,18 @@ void main() { }); }); - group('querySkuDetails', () { + group('queryProductDetails', () { const String queryMethodName = - 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + 'BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener)'; - test('handles empty skuDetails', () async { + test('handles empty productDetails', () async { const String debugMessage = 'dummy message'; const BillingResponse responseCode = BillingResponse.ok; const BillingResultWrapper expectedBillingResult = BillingResultWrapper( responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse(name: queryMethodName, value: { 'billingResult': buildBillingResultMap(expectedBillingResult), - 'skuDetailsList': >[], + 'productDetailsList': >[], }); final ProductDetailsResponse response = @@ -140,16 +140,22 @@ void main() { responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse(name: queryMethodName, value: { 'billingResult': buildBillingResultMap(expectedBillingResult), - 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails) + ] }); - // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // Since queryProductDetails makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead // of 1. final ProductDetailsResponse response = await iapAndroidPlatform.queryProductDetails({'valid'}); - expect(response.productDetails.first.title, dummySkuDetails.title); + expect(response.productDetails.first.title, + dummyOneTimeProductDetails.title); expect(response.productDetails.first.description, - dummySkuDetails.description); - expect(response.productDetails.first.price, dummySkuDetails.price); + dummyOneTimeProductDetails.description); + expect( + response.productDetails.first.price, + dummyOneTimeProductDetails + .oneTimePurchaseOfferDetails?.formattedPrice); expect(response.productDetails.first.currencySymbol, r'$'); }); @@ -160,9 +166,11 @@ void main() { responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse(name: queryMethodName, value: { 'billingResult': buildBillingResultMap(expectedBillingResult), - 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails) + ] }); - // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // Since queryProductDetails makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead // of 1. final ProductDetailsResponse response = await iapAndroidPlatform.queryProductDetails({'invalid'}); @@ -178,8 +186,8 @@ void main() { value: { 'responseCode': const BillingResponseConverter().toJson(responseCode), - 'skuDetailsList': >[ - buildSkuMap(dummySkuDetails) + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails) ] }, additionalStepBeforeReturn: (dynamic _) { @@ -189,7 +197,7 @@ void main() { details: {'info': 'error_info'}, ); }); - // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // Since queryProductDetails makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead // of 1. final ProductDetailsResponse response = await iapAndroidPlatform.queryProductDetails({'invalid'}); @@ -266,7 +274,7 @@ void main() { ); }); - test('returns SkuDetailsResponseWrapper', () async { + test('returns ProductDetailsResponseWrapper', () async { final Completer> completer = Completer>(); final Stream> stream = @@ -294,7 +302,7 @@ void main() { }); // Since queryPastPurchases makes 2 platform method calls (one for each - // SkuType), the result will contain 2 dummyPurchase instances instead + // ProductType), the result will contain 2 dummyPurchase instances instead // of 1. await iapAndroidPlatform.restorePurchases(); final List restoredPurchases = await completer.future; @@ -304,7 +312,7 @@ void main() { final GooglePlayPurchaseDetails purchase = element as GooglePlayPurchaseDetails; - expect(purchase.productID, dummyPurchase.sku); + expect(purchase.productID, dummyPurchase.products.first); expect(purchase.purchaseID, dummyPurchase.orderId); expect(purchase.verificationData.localVerificationData, dummyPurchase.originalJson); @@ -325,7 +333,7 @@ void main() { 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; test('buy non consumable, serializes and deserializes data', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.ok; @@ -344,7 +352,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -370,7 +378,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyNonConsumable( purchaseParam: purchaseParam); @@ -379,11 +388,11 @@ void main() { expect(launchResult, isTrue); expect(result.purchaseID, 'orderID1'); expect(result.status, PurchaseStatus.purchased); - expect(result.productID, dummySkuDetails.sku); + expect(result.productID, productDetails.productId); }); test('handles an error with an empty purchases list', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.error; @@ -414,7 +423,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); final PurchaseDetails result = await completer.future; @@ -427,7 +437,7 @@ void main() { test('buy consumable with auto consume, serializes and deserializes data', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.ok; @@ -446,7 +456,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -487,7 +497,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -515,8 +526,9 @@ void main() { final bool result = await iapAndroidPlatform.buyNonConsumable( purchaseParam: GooglePlayPurchaseParam( - productDetails: - GooglePlayProductDetailsV4.fromSkuDetails(dummySkuDetails))); + productDetails: GooglePlayProductDetails.fromProductDetails( + dummyOneTimeProductDetails) + .first)); // Verify that the failure has been converted and returned expect(result, isFalse); @@ -535,15 +547,16 @@ void main() { final bool result = await iapAndroidPlatform.buyConsumable( purchaseParam: GooglePlayPurchaseParam( - productDetails: - GooglePlayProductDetailsV4.fromSkuDetails(dummySkuDetails))); + productDetails: GooglePlayProductDetails.fromProductDetails( + dummyOneTimeProductDetails) + .first)); // Verify that the failure has been converted and returned expect(result, isFalse); }); test('adds consumption failures to PurchaseDetails objects', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.ok; @@ -561,7 +574,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -602,7 +615,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -620,7 +634,7 @@ void main() { test( 'buy consumable without auto consume, consume api should not receive calls', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.developerError; @@ -639,7 +653,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -677,7 +691,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable( purchaseParam: purchaseParam, autoConsume: false); @@ -687,7 +702,7 @@ void main() { test( 'should get canceled purchase status when response code is BillingResponse.userCanceled', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.userCanceled; @@ -705,7 +720,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'sku': skuDetails.sku, + 'product': productDetails.productId, 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -746,7 +761,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -759,7 +775,7 @@ void main() { test( 'should get purchased purchase status when upgrading subscription by deferred proration mode', () async { - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; const String accountId = 'hashedAccountId'; const String debugMessage = 'dummy message'; const BillingResponse sentCode = BillingResponse.ok; @@ -790,7 +806,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetailsV4.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: GooglePlayPurchaseDetails.fromPurchase( From a6819485b5ea406d072dd3867c31528b796b4ed6 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 13:16:39 +0200 Subject: [PATCH 03/17] Update java tests --- .../inapppurchase/InAppPurchasePlugin.java | 1 - .../plugins/inapppurchase/Translator.java | 2 +- .../inapppurchase/MethodCallHandlerTest.java | 449 ++++++++---------- .../plugins/inapppurchase/TranslatorTest.java | 145 ++++-- .../lib/billing_client_wrappers.dart | 1 + .../billing_client_wrapper.dart | 1 - .../product_wrapper.dart} | 2 +- .../subscription_offer_details_wrapper.dart | 8 +- .../subscription_offer_details_wrapper.g.dart | 2 +- .../types/google_play_product_details.dart | 3 +- .../lib/src/types/types.dart | 1 - .../billing_client_wrapper_test.dart | 1 - .../product_details_wrapper_test.dart | 8 +- 13 files changed, 310 insertions(+), 314 deletions(-) rename packages/in_app_purchase/in_app_purchase_android/lib/src/{types/product.dart => billing_client_wrappers/product_wrapper.dart} (60%) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index d2189a835c17..7fec8330b670 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -38,7 +38,6 @@ static final class MethodNames { "BillingClient#launchBillingFlow(Activity, BillingFlowParams)"; static final String ON_PURCHASES_UPDATED = "PurchasesUpdatedListener#onPurchasesUpdated(int, List)"; - static final String QUERY_PURCHASES = "BillingClient#queryPurchases(String)"; static final String QUERY_PURCHASES_ASYNC = "BillingClient#queryPurchasesAsync(String)"; static final String QUERY_PURCHASE_HISTORY_ASYNC = "BillingClient#queryPurchaseHistoryAsync(String)"; static final String CONSUME_PURCHASE_ASYNC = diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index e42bdce80fe7..53e457274c73 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -90,7 +90,7 @@ static HashMap fromSubscriptionOfferDetails(@Nullable ProductDet serialized.put("offerId", subscriptionOfferDetails.getOfferId()); serialized.put("basePlanId", subscriptionOfferDetails.getBasePlanId()); serialized.put("offerTags", subscriptionOfferDetails.getOfferTags()); - serialized.put("offerToken", subscriptionOfferDetails.getOfferToken()); + serialized.put("offerIdToken", subscriptionOfferDetails.getOfferToken()); ProductDetails.PricingPhases pricingPhases = subscriptionOfferDetails.getPricingPhases(); serialized.put("pricingPhases", fromPricingPhases(pricingPhases)); diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index a3d1aa037188..f14e233de5d9 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -9,18 +9,17 @@ import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW_V4; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES_ASYNC_V4; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC_V4; -import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PRODUCT_DETAILS; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES_ASYNC; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.START_CONNECTION; import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; +import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; -import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static java.util.Collections.unmodifiableList; @@ -51,23 +50,30 @@ import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.ProductDetails; +import com.android.billingclient.api.ProductDetailsResponseListener; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.PurchasesResponseListener; +import com.android.billingclient.api.QueryProductDetailsParams; import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchasesParams; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import org.json.JSONException; + import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -77,7 +83,6 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; -@SuppressWarnings("deprecation") public class MethodCallHandlerTest { private MethodCallHandlerImpl methodChannelHandler; private BillingClientFactory factory; @@ -209,60 +214,58 @@ public void endConnection() { verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation); } - @Test - public void querySkuDetailsAsync() { - // Connect a billing client and set up the SKU query listeners - establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); - String skuType = BillingClient.SkuType.INAPP; - List skusList = asList("id1", "id2"); - HashMap arguments = new HashMap<>(); - arguments.put("skuType", skuType); - arguments.put("skusList", skusList); - MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); - - // Query for SKU details - methodChannelHandler.onMethodCall(queryCall, result); - - // Assert the arguments were forwarded correctly to BillingClient - ArgumentCaptor paramCaptor = - ArgumentCaptor.forClass(com.android.billingclient.api.SkuDetailsParams.class); - ArgumentCaptor listenerCaptor = - ArgumentCaptor.forClass(com.android.billingclient.api.SkuDetailsResponseListener.class); - verify(mockBillingClient).querySkuDetailsAsync(paramCaptor.capture(), listenerCaptor.capture()); - assertEquals(paramCaptor.getValue().getSkuType(), skuType); - assertEquals(paramCaptor.getValue().getSkusList(), skusList); - - // Assert that we handed result BillingClient's response - int responseCode = 200; - List skuDetailsResponse = - asList(buildSkuDetails("foo")); - BillingResult billingResult = - BillingResult.newBuilder() - .setResponseCode(100) - .setDebugMessage("dummy debug message") - .build(); - listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); - @SuppressWarnings("unchecked") - ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); - verify(result).success(resultCaptor.capture()); - HashMap resultData = resultCaptor.getValue(); - assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); - assertEquals(resultData.get("skuDetailsList"), fromSkuDetailsList(skuDetailsResponse)); - } + @Test + public void queryProductDetailsAsync() { + // Connect a billing client and set up the product query listeners + establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); + String productType = BillingClient.ProductType.INAPP; + List productsList = asList("id1", "id2"); + HashMap arguments = new HashMap<>(); + arguments.put("productTypes", Arrays.asList(productType, productType)); + arguments.put("productIds", productsList); + MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); + + // Query for product details + methodChannelHandler.onMethodCall(queryCall, result); + + // Assert the arguments were forwarded correctly to BillingClient + ArgumentCaptor paramCaptor = + ArgumentCaptor.forClass(QueryProductDetailsParams.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(ProductDetailsResponseListener.class); + verify(mockBillingClient).queryProductDetailsAsync(paramCaptor.capture(), listenerCaptor.capture()); + + // Assert that we handed result BillingClient's response + int responseCode = 200; + List productDetailsResponse = + asList(buildProductDetails("foo")); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); + @SuppressWarnings("unchecked") + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(result).success(resultCaptor.capture()); + HashMap resultData = resultCaptor.getValue(); + assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); + assertEquals(resultData.get("productDetailsList"), fromProductDetailsList(productDetailsResponse)); + } @Test - public void querySkuDetailsAsync_clientDisconnected() { - // Disconnect the Billing client and prepare a querySkuDetails call + public void queryProductDetailsAsync_clientDisconnected() { + // Disconnect the Billing client and prepare a queryProductDetails call MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class)); - String skuType = BillingClient.SkuType.INAPP; - List skusList = asList("id1", "id2"); + String productType = BillingClient.ProductType.INAPP; + List productsList = asList("id1", "id2"); HashMap arguments = new HashMap<>(); - arguments.put("skuType", skuType); - arguments.put("skusList", skusList); - MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); + arguments.put("productTypes", Arrays.asList(productType, productType)); + arguments.put("productIds", productsList); + MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); - // Query for SKU details + // Query for product details methodChannelHandler.onMethodCall(queryCall, result); // Assert that we sent an error back. @@ -275,14 +278,14 @@ public void querySkuDetailsAsync_clientDisconnected() { // since PBL 3.0, the `accountId` variable is not public. @Test public void launchBillingFlow_null_AccountId_do_not_crash() { - // Fetch the sku details first and then prepare the launch billing flow call - String skuId = "foo"; - queryForSkus(singletonList(skuId)); + // Fetch the product details first and then prepare the launch billing flow call + String productId = "foo"; + queryForProducts(singletonList(productId)); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", null); arguments.put("obfuscatedProfileId", null); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -305,16 +308,16 @@ public void launchBillingFlow_null_AccountId_do_not_crash() { } @Test - public void launchBillingFlow_ok_null_OldSku() { - // Fetch the sku details first and then prepare the launch billing flow call - String skuId = "foo"; + public void launchBillingFlow_ok_null_OldProduct() { + // Fetch the product details first and then prepare the launch billing flow call + String productId = "foo"; String accountId = "account"; - queryForSkus(singletonList(skuId)); + queryForProducts(singletonList(productId)); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", null); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + arguments.put("oldProduct", null); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -339,14 +342,14 @@ public void launchBillingFlow_ok_null_OldSku() { public void launchBillingFlow_ok_null_Activity() { methodChannelHandler.setActivity(null); - // Fetch the sku details first and then prepare the launch billing flow call - String skuId = "foo"; + // Fetch the product details first and then prepare the launch billing flow call + String productId = "foo"; String accountId = "account"; - queryForSkus(singletonList(skuId)); + queryForProducts(singletonList(productId)); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); methodChannelHandler.onMethodCall(launchCall, result); // Verify we pass the response code to result @@ -355,17 +358,17 @@ public void launchBillingFlow_ok_null_Activity() { } @Test - public void launchBillingFlow_ok_oldSku() { - // Fetch the sku details first and query the method call - String skuId = "foo"; + public void launchBillingFlow_ok_oldProduct() { + // Fetch the product details first and query the method call + String productId = "foo"; String accountId = "account"; - String oldSkuId = "oldFoo"; - queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + String oldProductId = "oldFoo"; + queryForProducts(unmodifiableList(asList(productId, oldProductId))); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", oldSkuId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + arguments.put("oldProduct", oldProductId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -389,14 +392,14 @@ public void launchBillingFlow_ok_oldSku() { @Test public void launchBillingFlow_ok_AccountId() { - // Fetch the sku details first and query the method call - String skuId = "foo"; + // Fetch the product details first and query the method call + String productId = "foo"; String accountId = "account"; - queryForSkus(singletonList(skuId)); + queryForProducts(singletonList(productId)); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -420,20 +423,20 @@ public void launchBillingFlow_ok_AccountId() { @Test public void launchBillingFlow_ok_Proration() { - // Fetch the sku details first and query the method call - String skuId = "foo"; - String oldSkuId = "oldFoo"; + // Fetch the product details first and query the method call + String productId = "foo"; + String oldProductId = "oldFoo"; String purchaseToken = "purchaseTokenFoo"; String accountId = "account"; int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; - queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + queryForProducts(unmodifiableList(asList(productId, oldProductId))); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", oldSkuId); + arguments.put("oldProduct", oldProductId); arguments.put("purchaseToken", purchaseToken); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -456,20 +459,20 @@ public void launchBillingFlow_ok_Proration() { } @Test - public void launchBillingFlow_ok_Proration_with_null_OldSku() { - // Fetch the sku details first and query the method call - String skuId = "foo"; + public void launchBillingFlow_ok_Proration_with_null_OldProduct() { + // Fetch the product details first and query the method call + String productId = "foo"; String accountId = "account"; - String queryOldSkuId = "oldFoo"; - String oldSkuId = null; + String queryOldProductId = "oldFoo"; + String oldProductId = null; int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; - queryForSkus(unmodifiableList(asList(skuId, queryOldSkuId))); + queryForProducts(unmodifiableList(asList(productId, queryOldProductId))); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", oldSkuId); + arguments.put("oldProduct", oldProductId); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -483,28 +486,28 @@ public void launchBillingFlow_ok_Proration_with_null_OldSku() { // Assert that we sent an error back. verify(result) .error( - contains("IN_APP_PURCHASE_REQUIRE_OLD_SKU"), - contains("launchBillingFlow failed because oldSku is null"), + contains("IN_APP_PURCHASE_REQUIRE_OLD_PRODUCT"), + contains("launchBillingFlow failed because oldProduct is null"), any()); verify(result, never()).success(any()); } @Test public void launchBillingFlow_ok_Full() { - // Fetch the sku details first and query the method call - String skuId = "foo"; - String oldSkuId = "oldFoo"; + // Fetch the product details first and query the method call + String productId = "foo"; + String oldProductId = "oldFoo"; String purchaseToken = "purchaseTokenFoo"; String accountId = "account"; int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE; - queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + queryForProducts(unmodifiableList(asList(productId, oldProductId))); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", oldSkuId); + arguments.put("oldProduct", oldProductId); arguments.put("purchaseToken", purchaseToken); arguments.put("prorationMode", prorationMode); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow BillingResult billingResult = @@ -531,12 +534,12 @@ public void launchBillingFlow_clientDisconnected() { // Prepare the launch call after disconnecting the client MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class)); - String skuId = "foo"; + String productId = "foo"; String accountId = "account"; HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); methodChannelHandler.onMethodCall(launchCall, result); @@ -546,41 +549,41 @@ public void launchBillingFlow_clientDisconnected() { } @Test - public void launchBillingFlow_skuNotFound() { - // Try to launch the billing flow for a random sku ID + public void launchBillingFlow_productNotFound() { + // Try to launch the billing flow for a random product ID establishConnectedBillingClient(null, null); - String skuId = "foo"; + String productId = "foo"; String accountId = "account"; HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); methodChannelHandler.onMethodCall(launchCall, result); // Assert that we sent an error back. - verify(result).error(contains("NOT_FOUND"), contains(skuId), any()); + verify(result).error(contains("NOT_FOUND"), contains(productId), any()); verify(result, never()).success(any()); } @Test - public void launchBillingFlow_oldSkuNotFound() { - // Try to launch the billing flow for a random sku ID + public void launchBillingFlow_oldProductNotFound() { + // Try to launch the billing flow for a random product ID establishConnectedBillingClient(null, null); - String skuId = "foo"; + String productId = "foo"; String accountId = "account"; - String oldSkuId = "oldSku"; - queryForSkus(singletonList(skuId)); + String oldProductId = "oldProduct"; + queryForProducts(singletonList(productId)); HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); + arguments.put("product", productId); arguments.put("accountId", accountId); - arguments.put("oldSku", oldSkuId); - MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW_V4, arguments); + arguments.put("oldProduct", oldProductId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); methodChannelHandler.onMethodCall(launchCall, result); // Assert that we sent an error back. - verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_SKU"), contains(oldSkuId), any()); + verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_PRODUCT"), contains(oldProductId), any()); verify(result, never()).success(any()); } @@ -590,8 +593,8 @@ public void queryPurchases_clientDisconnected() { methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class)); HashMap arguments = new HashMap<>(); - arguments.put("skuType", BillingClient.SkuType.INAPP); - methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC_V4, arguments), result); + arguments.put("type", BillingClient.ProductType.INAPP); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC, arguments), result); // Assert that we sent an error back. verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); @@ -633,8 +636,8 @@ public Object answer(InvocationOnMock invocation) { any(QueryPurchasesParams.class), purchasesResponseListenerArgumentCaptor.capture()); HashMap arguments = new HashMap<>(); - arguments.put("skuType", BillingClient.SkuType.INAPP); - methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC_V4, arguments), result); + arguments.put("productType", BillingClient.ProductType.INAPP); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC, arguments), result); lock.await(5000, TimeUnit.MILLISECONDS); @@ -664,12 +667,12 @@ public void queryPurchaseHistoryAsync() { .build(); List purchasesList = asList(buildPurchaseHistoryRecord("foo")); HashMap arguments = new HashMap<>(); - arguments.put("skuType", BillingClient.SkuType.INAPP); + arguments.put("productType", BillingClient.ProductType.INAPP); ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(PurchaseHistoryResponseListener.class); methodChannelHandler.onMethodCall( - new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC_V4, arguments), result); + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); // Verify we pass the data to result verify(mockBillingClient) @@ -688,9 +691,9 @@ public void queryPurchaseHistoryAsync_clientDisconnected() { methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class)); HashMap arguments = new HashMap<>(); - arguments.put("skuType", BillingClient.SkuType.INAPP); + arguments.put("type", BillingClient.ProductType.INAPP); methodChannelHandler.onMethodCall( - new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC_V4, arguments), result); + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); // Assert that we sent an error back. verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); @@ -827,102 +830,6 @@ public void isFutureSupported_false() { verify(result).success(false); } - @Test - public void launchPriceChangeConfirmationFlow() { - // Set up the sku details - establishConnectedBillingClient(null, null); - String skuId = "foo"; - queryForSkus(singletonList(skuId)); - - BillingResult billingResult = - BillingResult.newBuilder() - .setResponseCode(BillingClient.BillingResponseCode.OK) - .setDebugMessage("dummy debug message") - .build(); - - // Set up the mock billing client - ArgumentCaptor - priceChangeConfirmationListenerArgumentCaptor = - ArgumentCaptor.forClass( - com.android.billingclient.api.PriceChangeConfirmationListener.class); - ArgumentCaptor - priceChangeFlowParamsArgumentCaptor = - ArgumentCaptor.forClass(com.android.billingclient.api.PriceChangeFlowParams.class); - doNothing() - .when(mockBillingClient) - .launchPriceChangeConfirmationFlow( - any(), - priceChangeFlowParamsArgumentCaptor.capture(), - priceChangeConfirmationListenerArgumentCaptor.capture()); - - // Call the methodChannelHandler - HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); - methodChannelHandler.onMethodCall( - new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); - - // Verify the price change params. - com.android.billingclient.api.PriceChangeFlowParams priceChangeFlowParams = - priceChangeFlowParamsArgumentCaptor.getValue(); - assertEquals(skuId, priceChangeFlowParams.getSkuDetails().getSku()); - - // Set the response in the callback - com.android.billingclient.api.PriceChangeConfirmationListener priceChangeConfirmationListener = - priceChangeConfirmationListenerArgumentCaptor.getValue(); - priceChangeConfirmationListener.onPriceChangeConfirmationResult(billingResult); - - // Verify we pass the response to result - verify(result, never()).error(any(), any(), any()); - @SuppressWarnings("unchecked") - ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); - verify(result, times(1)).success(resultCaptor.capture()); - assertEquals(fromBillingResult(billingResult), resultCaptor.getValue()); - } - - @Test - public void launchPriceChangeConfirmationFlow_withoutActivity_returnsActivityUnavailableError() { - // Set up the sku details - establishConnectedBillingClient(null, null); - String skuId = "foo"; - queryForSkus(singletonList(skuId)); - - methodChannelHandler.setActivity(null); - - // Call the methodChannelHandler - HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); - methodChannelHandler.onMethodCall( - new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); - verify(result, times(1)).error(eq("ACTIVITY_UNAVAILABLE"), any(), any()); - } - - @Test - public void launchPriceChangeConfirmationFlow_withoutSkuQuery_returnsNotFoundError() { - // Set up the sku details - establishConnectedBillingClient(null, null); - String skuId = "foo"; - - // Call the methodChannelHandler - HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); - methodChannelHandler.onMethodCall( - new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); - verify(result, times(1)).error(eq("NOT_FOUND"), contains("sku"), any()); - } - - @Test - public void launchPriceChangeConfirmationFlow_withoutBillingClient_returnsUnavailableError() { - // Set up the sku details - String skuId = "foo"; - - // Call the methodChannelHandler - HashMap arguments = new HashMap<>(); - arguments.put("sku", skuId); - methodChannelHandler.onMethodCall( - new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result); - verify(result, times(1)).error(eq("UNAVAILABLE"), contains("BillingClient"), any()); - } - private ArgumentCaptor mockStartConnection() { Map arguments = new HashMap<>(); arguments.put("handle", 1); @@ -949,46 +856,70 @@ private void establishConnectedBillingClient( methodChannelHandler.onMethodCall(connectCall, result); } - private void queryForSkus(List skusList) { + private void queryForProducts(List productsList) { // Set up the query method call establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); HashMap arguments = new HashMap<>(); - String skuType = BillingClient.SkuType.INAPP; - arguments.put("skuType", skuType); - arguments.put("skusList", skusList); - MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); + String productType = BillingClient.ProductType.INAPP; + List productTypes = new ArrayList<>(); + for (String ignored : + productsList) { + productTypes.add(productType); + } + arguments.put("productTypes", productTypes); + arguments.put("productIds", productsList); + MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); // Call the method. methodChannelHandler.onMethodCall(queryCall, mock(Result.class)); - // Respond to the call with a matching set of Sku details. - ArgumentCaptor listenerCaptor = - ArgumentCaptor.forClass(com.android.billingclient.api.SkuDetailsResponseListener.class); - verify(mockBillingClient).querySkuDetailsAsync(any(), listenerCaptor.capture()); - List skuDetailsResponse = - skusList.stream().map(this::buildSkuDetails).collect(toList()); + // Respond to the call with a matching set of product details. + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(ProductDetailsResponseListener.class); + verify(mockBillingClient).queryProductDetailsAsync(any(), listenerCaptor.capture()); + List productDetailsResponse = + productsList.stream().map(this::buildProductDetails).collect(toList()); BillingResult billingResult = BillingResult.newBuilder() .setResponseCode(100) .setDebugMessage("dummy debug message") .build(); - listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); - } - - private com.android.billingclient.api.SkuDetails buildSkuDetails(String id) { - String json = - String.format( - "{\"packageName\": \"dummyPackageName\",\"productId\":\"%s\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}", - id); - com.android.billingclient.api.SkuDetails details = null; - try { - details = new com.android.billingclient.api.SkuDetails(json); - } catch (JSONException e) { - fail("buildSkuDetails failed with JSONException " + e.toString()); + listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); + } + + private ProductDetails buildProductDetails(String id) { + String json = + String.format( + "{\n" + + " \"title\": \"Example title\",\n" + + " \"description\": \"Example description\",\n" + + " \"productId\": \"%s\",\n" + + " \"type\": \"inapp\",\n" + + " \"name\": \"Example name\",\n" + + " \"oneTimePurchaseOfferDetails\": {\n" + + " \"priceAmountMicros\": 990000,\n" + + " \"priceCurrencyCode\": \"USD\",\n" + + " \"formattedPrice\": \"$0.99\"\n" + + " }\n" + + "}", + id); + + try { + Constructor productDetailsConstructor = ProductDetails.class.getDeclaredConstructor(String.class); + productDetailsConstructor.setAccessible(true); + return productDetailsConstructor.newInstance(json); + } catch (NoSuchMethodException e) { + fail("buildProductDetails failed with NoSuchMethodException " + e); + } catch (InvocationTargetException e) { + fail("buildProductDetails failed with InvocationTargetException " + e); + } catch (IllegalAccessException e) { + fail("buildProductDetails failed with IllegalAccessException " + e); + } catch (InstantiationException e) { + fail("buildProductDetails failed with InstantiationException " + e); + } + return null; } - return details; - } private Purchase buildPurchase(String orderId) { Purchase purchase = mock(Purchase.class); diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index 288aae4cdfab..4561f45a3577 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -14,8 +14,12 @@ import com.android.billingclient.api.AccountIdentifiers; import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -26,39 +30,49 @@ import org.junit.Before; import org.junit.Test; -@SuppressWarnings("deprecation") public class TranslatorTest { - private static final String SKU_DETAIL_EXAMPLE_JSON = - "{\"productId\":\"example\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}"; private static final String PURCHASE_EXAMPLE_JSON = - "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\", \"obfuscatedAccountId\":\"Account101\", \"obfuscatedProfileId\": \"Profile105\"}"; + "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\", \"obfuscatedAccountId\":\"Account101\", \"obfuscatedProfileId\":\"Profile105\"}"; + private static final String IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON = "{\"title\":\"Example title\",\"description\":\"Example description\",\"productId\":\"Example id\",\"type\":\"inapp\",\"name\":\"Example name\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":990000,\"priceCurrencyCode\":\"USD\",\"formattedPrice\":\"$0.99\"}}"; + private static final String SUBS_PRODUCT_DETAIL_EXAMPLE_JSON = "{\"title\":\"Example title 2\",\"description\":\"Example description 2\",\"productId\":\"Example id 2\",\"type\":\"subs\",\"name\":\"Example name 2\",\"subscriptionOfferDetails\":[{\"offerId\":\"Example offer id\",\"basePlanId\":\"Example base plan id\",\"offerTags\":[\"Example offer tag\"],\"offerIdToken\":\"Example offer token\",\"pricingPhases\":[{\"formattedPrice\":\"$0.99\",\"priceCurrencyCode\":\"USD\",\"priceAmountMicros\":990000,\"billingCycleCount\":4,\"billingPeriod\":\"Example billing period\",\"recurrenceMode\":0}]}]}"; + + Constructor productDetailsConstructor; @Before - public void setup() { + public void setup() throws NoSuchMethodException { Locale locale = new Locale("en", "us"); Locale.setDefault(locale); + + productDetailsConstructor = ProductDetails.class.getDeclaredConstructor(String.class); + productDetailsConstructor.setAccessible(true); + } + + @Test + public void fromInAppProductDetail() throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON); + + Map serialized = Translator.fromProductDetail(expected); + + assertSerialized(expected, serialized); } @Test - public void fromSkuDetail() throws JSONException { - final com.android.billingclient.api.SkuDetails expected = - new com.android.billingclient.api.SkuDetails(SKU_DETAIL_EXAMPLE_JSON); + public void fromSubsProductDetail() throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON); - Map serialized = Translator.fromSkuDetail(expected); + Map serialized = Translator.fromProductDetail(expected); assertSerialized(expected, serialized); } @Test - public void fromSkuDetailsList() throws JSONException { - final String SKU_DETAIL_EXAMPLE_2_JSON = - "{\"productId\":\"example2\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}"; - final List expected = + public void fromProductDetailsList() throws InvocationTargetException, IllegalAccessException, InstantiationException { + final List expected = Arrays.asList( - new com.android.billingclient.api.SkuDetails(SKU_DETAIL_EXAMPLE_JSON), - new com.android.billingclient.api.SkuDetails(SKU_DETAIL_EXAMPLE_2_JSON)); + productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON), + productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON)); - final List> serialized = Translator.fromSkuDetailsList(expected); + final List> serialized = Translator.fromProductDetailsList(expected); assertEquals(expected.size(), serialized.size()); assertSerialized(expected.get(0), serialized.get(0)); @@ -66,8 +80,8 @@ public void fromSkuDetailsList() throws JSONException { } @Test - public void fromSkuDetailsList_null() { - assertEquals(Collections.emptyList(), Translator.fromSkuDetailsList(null)); + public void fromProductDetailsList_null() { + assertEquals(Collections.emptyList(), Translator.fromProductDetailsList(null)); } @Test @@ -138,7 +152,7 @@ public void fromPurchasesList_null() { } @Test - public void fromBillingResult() throws JSONException { + public void fromBillingResult() { BillingResult newBillingResult = BillingResult.newBuilder() .setDebugMessage("dummy debug message") @@ -151,7 +165,7 @@ public void fromBillingResult() throws JSONException { } @Test - public void fromBillingResult_debugMessageNull() throws JSONException { + public void fromBillingResult_debugMessageNull() { BillingResult newBillingResult = BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(); Map billingResultMap = Translator.fromBillingResult(newBillingResult); @@ -172,26 +186,79 @@ public void currencyCodeFromSymbol() { } private void assertSerialized( - com.android.billingclient.api.SkuDetails expected, Map serialized) { + ProductDetails expected, Map serialized) { assertEquals(expected.getDescription(), serialized.get("description")); - assertEquals(expected.getFreeTrialPeriod(), serialized.get("freeTrialPeriod")); - assertEquals(expected.getIntroductoryPrice(), serialized.get("introductoryPrice")); - assertEquals( - expected.getIntroductoryPriceAmountMicros(), - serialized.get("introductoryPriceAmountMicros")); - assertEquals(expected.getIntroductoryPriceCycles(), serialized.get("introductoryPriceCycles")); - assertEquals(expected.getIntroductoryPricePeriod(), serialized.get("introductoryPricePeriod")); - assertEquals(expected.getPrice(), serialized.get("price")); - assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); - assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); - assertEquals("$", serialized.get("priceCurrencySymbol")); - assertEquals(expected.getSku(), serialized.get("sku")); - assertEquals(expected.getSubscriptionPeriod(), serialized.get("subscriptionPeriod")); assertEquals(expected.getTitle(), serialized.get("title")); - assertEquals(expected.getType(), serialized.get("type")); - assertEquals(expected.getOriginalPrice(), serialized.get("originalPrice")); + assertEquals(expected.getName(), serialized.get("name")); assertEquals( - expected.getOriginalPriceAmountMicros(), serialized.get("originalPriceAmountMicros")); + expected.getProductId(), + serialized.get("productId")); + assertEquals(expected.getProductType(), serialized.get("productType")); + + ProductDetails.OneTimePurchaseOfferDetails expectedOneTimePurchaseOfferDetails = expected.getOneTimePurchaseOfferDetails(); + Object oneTimePurchaseOfferDetailsObject = serialized.get("oneTimePurchaseOfferDetails"); + assertEquals(expectedOneTimePurchaseOfferDetails == null, oneTimePurchaseOfferDetailsObject == null); + if (expectedOneTimePurchaseOfferDetails != null && oneTimePurchaseOfferDetailsObject != null) { + @SuppressWarnings(value="unchecked") + Map oneTimePurchaseOfferDetailsMap = (Map) oneTimePurchaseOfferDetailsObject; + assertSerialized(expectedOneTimePurchaseOfferDetails, oneTimePurchaseOfferDetailsMap); + } + + List expectedSubscriptionOfferDetailsList = expected.getSubscriptionOfferDetails(); + Object subscriptionOfferDetailsListObject = serialized.get("subscriptionOfferDetails"); + assertEquals(expectedSubscriptionOfferDetailsList == null, subscriptionOfferDetailsListObject == null); + if (expectedSubscriptionOfferDetailsList != null && subscriptionOfferDetailsListObject != null) { + @SuppressWarnings(value="unchecked") + List subscriptionOfferDetailsListList = (List) subscriptionOfferDetailsListObject; + assertSerialized(expectedSubscriptionOfferDetailsList, subscriptionOfferDetailsListList); + } + } + + private void assertSerialized(ProductDetails.OneTimePurchaseOfferDetails expected, Map serialized) { + assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); + assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals(expected.getFormattedPrice(), serialized.get("formattedPrice")); + } + + private void assertSerialized(List expected, List serialized) { + assertEquals(expected.size(), serialized.size()); + for (int i = 0; i < expected.size(); i++) { + @SuppressWarnings(value="unchecked") + Map serializedMap = (Map) serialized.get(i); + assertSerialized(expected.get(i), serializedMap); + } + } + + private void assertSerialized(ProductDetails.SubscriptionOfferDetails expected, Map serialized) { + assertEquals(expected.getBasePlanId(), serialized.get("basePlanId")); + assertEquals(expected.getOfferId(), serialized.get("offerId")); + assertEquals(expected.getOfferTags(), serialized.get("offerTags")); + assertEquals(expected.getOfferToken(), serialized.get("offerIdToken")); + + @SuppressWarnings(value="unchecked") + List serializedPricingPhases = (List) serialized.get("pricingPhases"); + assertNotNull(serializedPricingPhases); + assertSerialized(expected.getPricingPhases(), serializedPricingPhases); + } + + private void assertSerialized(ProductDetails.PricingPhases expected, List serialized) { + List expectedPhases = expected.getPricingPhaseList(); + assertEquals(expectedPhases.size(), serialized.size()); + for (int i = 0; i < serialized.size(); i++) { + @SuppressWarnings(value="unchecked") + Map pricingPhaseMap = (Map) serialized.get(i); + assertSerialized(expectedPhases.get(i), pricingPhaseMap); + } + expected.getPricingPhaseList(); + } + + private void assertSerialized(ProductDetails.PricingPhase expected, Map serialized) { + assertEquals(expected.getFormattedPrice(), serialized.get("formattedPrice")); + assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); + assertEquals(expected.getBillingCycleCount(), serialized.get("billingCycleCount")); + assertEquals(expected.getBillingPeriod(), serialized.get("billingPeriod")); + assertEquals(expected.getRecurrenceMode(), serialized.get("recurrenceMode")); } private void assertSerialized(Purchase expected, Map serialized) { @@ -201,7 +268,7 @@ private void assertSerialized(Purchase expected, Map serialized) assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); assertEquals(expected.getSignature(), serialized.get("signature")); assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); - assertEquals(expected.getSkus(), serialized.get("skus")); + assertEquals(expected.getProducts(), serialized.get("products")); assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); assertEquals(expected.isAcknowledged(), serialized.get("isAcknowledged")); assertEquals(expected.getPurchaseState(), serialized.get("purchaseState")); @@ -220,7 +287,7 @@ private void assertSerialized(PurchaseHistoryRecord expected, Map[]) @@ -68,7 +68,7 @@ class SubscriptionOfferDetailsWrapper { other.basePlanId == basePlanId && other.offerId == offerId && listEquals(other.offerTags, offerTags) && - other.offerToken == offerToken && + other.offerIdToken == offerIdToken && listEquals(other.pricingPhases, pricingPhases); } @@ -78,7 +78,7 @@ class SubscriptionOfferDetailsWrapper { basePlanId.hashCode, offerId.hashCode, offerTags.hashCode, - offerToken.hashCode, + offerIdToken.hashCode, pricingPhases.hashCode, ); } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart index fb392e6251c3..eca645340fe5 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/subscription_offer_details_wrapper.g.dart @@ -15,7 +15,7 @@ SubscriptionOfferDetailsWrapper _$SubscriptionOfferDetailsWrapperFromJson( ?.map((e) => e as String) .toList() ?? [], - offerToken: json['offerToken'] as String? ?? '', + offerIdToken: json['offerIdToken'] as String? ?? '', pricingPhases: (json['pricingPhases'] as List?) ?.map((e) => PricingPhaseWrapper.fromJson( Map.from(e as Map))) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 1dd36224d0e0..7fb28b161bf8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -129,6 +129,7 @@ class GooglePlayProductDetails extends ProductDetails { /// object was contructed for, or 'null' if it was not a subscription. String? get offerToken => subscriptionIndex != null && productDetails.subscriptionOfferDetails != null - ? productDetails.subscriptionOfferDetails![subscriptionIndex!].offerToken + ? productDetails + .subscriptionOfferDetails![subscriptionIndex!].offerIdToken : null; } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart index d581348204db..0a43425f6e94 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart @@ -6,5 +6,4 @@ export 'change_subscription_param.dart'; export 'google_play_product_details.dart'; export 'google_play_purchase_details.dart'; export 'google_play_purchase_param.dart'; -export 'product.dart'; export 'query_purchase_details_response.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 6a82324735ff..0e34ee84c6dc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -6,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/src/channel.dart'; -import 'package:in_app_purchase_android/src/types/product.dart'; import '../stub_in_app_purchase_platform.dart'; import 'product_details_wrapper_test.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart index da65f9c92a04..3bd6a497490f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart @@ -31,7 +31,7 @@ const ProductDetailsWrapper dummySubscriptionProductDetails = basePlanId: 'basePlanId', offerTags: ['offerTags'], offerId: 'offerId', - offerToken: 'offerToken', + offerIdToken: 'offerToken', pricingPhases: [ PricingPhaseWrapper( billingCycleCount: 4, @@ -180,7 +180,7 @@ void main() { SubscriptionOfferDetailsWrapper( basePlanId: 'basePlanId', offerTags: ['offerTags'], - offerToken: 'offerToken', + offerIdToken: 'offerToken', pricingPhases: [ PricingPhaseWrapper( billingCycleCount: 4, @@ -210,7 +210,7 @@ void main() { SubscriptionOfferDetailsWrapper( basePlanId: 'basePlanId', offerTags: ['offerTags'], - offerToken: 'offerToken', + offerIdToken: 'offerToken', pricingPhases: [ PricingPhaseWrapper( billingCycleCount: 4, @@ -289,7 +289,7 @@ Map buildSubscriptionMap( 'offerId': original.offerId, 'basePlanId': original.basePlanId, 'offerTags': original.offerTags, - 'offerToken': original.offerToken, + 'offerIdToken': original.offerIdToken, 'pricingPhases': buildPricingPhaseMapList(original.pricingPhases), }; } From 643f765660eae65b1ba86bbe6176d35921d9aa1e Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 13:38:35 +0200 Subject: [PATCH 04/17] Format java files --- .../inapppurchase/InAppPurchasePlugin.java | 3 +- .../inapppurchase/MethodCallHandlerImpl.java | 119 ++++++++------- .../plugins/inapppurchase/Translator.java | 36 +++-- .../inapppurchase/MethodCallHandlerTest.java | 137 ++++++++---------- .../plugins/inapppurchase/TranslatorTest.java | 98 +++++++------ 5 files changed, 209 insertions(+), 184 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index 7fec8330b670..9486d109472e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -39,7 +39,8 @@ static final class MethodNames { static final String ON_PURCHASES_UPDATED = "PurchasesUpdatedListener#onPurchasesUpdated(int, List)"; static final String QUERY_PURCHASES_ASYNC = "BillingClient#queryPurchasesAsync(String)"; - static final String QUERY_PURCHASE_HISTORY_ASYNC = "BillingClient#queryPurchaseHistoryAsync(String)"; + static final String QUERY_PURCHASE_HISTORY_ASYNC = + "BillingClient#queryPurchaseHistoryAsync(String)"; static final String CONSUME_PURCHASE_ASYNC = "BillingClient#consumeAsync(String, ConsumeResponseListener)"; static final String ACKNOWLEDGE_PURCHASE = diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 5444e01e7ba3..abf68b3a863e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -35,10 +35,8 @@ import com.android.billingclient.api.QueryProductDetailsParams.Product; import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchasesParams; - import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; - import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -186,11 +184,10 @@ private void isReady(MethodChannel.Result result) { } private void queryProductDetailsAsync( - final List productIds, - final List productTypes, - final MethodChannel.Result result - ) { - assert(productIds.size() == productTypes.size()); + final List productIds, + final List productTypes, + final MethodChannel.Result result) { + assert (productIds.size() == productTypes.size()); if (billingClientError(result)) { return; @@ -198,34 +195,41 @@ private void queryProductDetailsAsync( List productList = new ArrayList<>(); for (int i = 0; i < productIds.size(); i++) { - productList.add(Product.newBuilder() + productList.add( + Product.newBuilder() .setProductId(productIds.get(i)) .setProductType(productTypes.get(i)) .build()); } - QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder().setProductList(productList).build(); - billingClient.queryProductDetailsAsync(params, new ProductDetailsResponseListener() { - @Override - public void onProductDetailsResponse(@NonNull BillingResult billingResult, @NonNull List productDetailsList) { - updateCachedProducts(productDetailsList); - final Map productDetailsResponse = new HashMap<>(); - productDetailsResponse.put("billingResult", fromBillingResult(billingResult)); - productDetailsResponse.put("productDetailsList", fromProductDetailsList(productDetailsList)); - result.success(productDetailsResponse); - } - }); + QueryProductDetailsParams params = + QueryProductDetailsParams.newBuilder().setProductList(productList).build(); + billingClient.queryProductDetailsAsync( + params, + new ProductDetailsResponseListener() { + @Override + public void onProductDetailsResponse( + @NonNull BillingResult billingResult, + @NonNull List productDetailsList) { + updateCachedProducts(productDetailsList); + final Map productDetailsResponse = new HashMap<>(); + productDetailsResponse.put("billingResult", fromBillingResult(billingResult)); + productDetailsResponse.put( + "productDetailsList", fromProductDetailsList(productDetailsList)); + result.success(productDetailsResponse); + } + }); } private void launchBillingFlow( - String product, - @Nullable String offerToken, - @Nullable String accountId, - @Nullable String obfuscatedProfileId, - @Nullable String oldProduct, - @Nullable String purchaseToken, - int prorationMode, - MethodChannel.Result result) { + String product, + @Nullable String offerToken, + @Nullable String accountId, + @Nullable String obfuscatedProfileId, + @Nullable String oldProduct, + @Nullable String purchaseToken, + int prorationMode, + MethodChannel.Result result) { if (billingClientError(result)) { return; } @@ -233,15 +237,17 @@ private void launchBillingFlow( com.android.billingclient.api.ProductDetails productDetails = cachedProducts.get(product); if (productDetails == null) { result.error( - "NOT_FOUND", - String.format( - "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", - product, LOAD_PRODUCT_DOC_URL), - null); + "NOT_FOUND", + String.format( + "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", + product, LOAD_PRODUCT_DOC_URL), + null); return; } - @Nullable List subscriptionOfferDetails = productDetails.getSubscriptionOfferDetails(); + @Nullable + List subscriptionOfferDetails = + productDetails.getSubscriptionOfferDetails(); if (subscriptionOfferDetails != null) { boolean isValidOfferToken = false; for (ProductDetails.SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) { @@ -252,42 +258,44 @@ private void launchBillingFlow( } if (!isValidOfferToken) { result.error( - "INVALID_OFFER_TOKEN", - String.format( - "Offer token %s for product %s is not valid. Make sure to only pass offer tokens that belong to the product. To obtain offer tokens for a product, fetch the products. An example of how to fetch the products could be found here: %s", - offerToken, product, LOAD_PRODUCT_DOC_URL), - null); + "INVALID_OFFER_TOKEN", + String.format( + "Offer token %s for product %s is not valid. Make sure to only pass offer tokens that belong to the product. To obtain offer tokens for a product, fetch the products. An example of how to fetch the products could be found here: %s", + offerToken, product, LOAD_PRODUCT_DOC_URL), + null); return; } } if (oldProduct == null - && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { + && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { result.error( - "IN_APP_PURCHASE_REQUIRE_OLD_PRODUCT", - "launchBillingFlow failed because oldProduct is null. You must provide a valid oldProduct in order to use a proration mode.", - null); + "IN_APP_PURCHASE_REQUIRE_OLD_PRODUCT", + "launchBillingFlow failed because oldProduct is null. You must provide a valid oldProduct in order to use a proration mode.", + null); return; } else if (oldProduct != null && !cachedProducts.containsKey(oldProduct)) { result.error( - "IN_APP_PURCHASE_INVALID_OLD_PRODUCT", - String.format( - "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", - oldProduct, LOAD_PRODUCT_DOC_URL), - null); + "IN_APP_PURCHASE_INVALID_OLD_PRODUCT", + String.format( + "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", + oldProduct, LOAD_PRODUCT_DOC_URL), + null); return; } if (activity == null) { result.error( - "ACTIVITY_UNAVAILABLE", - String.format("Details for product %s are not available. This method must be run with the app in foreground.", - product), - null); + "ACTIVITY_UNAVAILABLE", + String.format( + "Details for product %s are not available. This method must be run with the app in foreground.", + product), + null); return; } - BillingFlowParams.ProductDetailsParams.Builder productDetailsParamsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder(); + BillingFlowParams.ProductDetailsParams.Builder productDetailsParamsBuilder = + BillingFlowParams.ProductDetailsParams.newBuilder(); productDetailsParamsBuilder.setProductDetails(productDetails); if (offerToken != null) { productDetailsParamsBuilder.setOfferToken(offerToken); @@ -297,7 +305,7 @@ private void launchBillingFlow( productDetailsParamsList.add(productDetailsParamsBuilder.build()); BillingFlowParams.Builder paramsBuilder = - BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParamsList); + BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParamsList); if (accountId != null && !accountId.isEmpty()) { paramsBuilder.setObfuscatedAccountId(accountId); } @@ -305,7 +313,7 @@ private void launchBillingFlow( paramsBuilder.setObfuscatedProfileId(obfuscatedProfileId); } BillingFlowParams.SubscriptionUpdateParams.Builder subscriptionUpdateParamsBuilder = - BillingFlowParams.SubscriptionUpdateParams.newBuilder(); + BillingFlowParams.SubscriptionUpdateParams.newBuilder(); if (oldProduct != null && !oldProduct.isEmpty() && purchaseToken != null) { subscriptionUpdateParamsBuilder.setOldPurchaseToken(purchaseToken); // The proration mode value has to match one of the following declared in @@ -314,8 +322,7 @@ private void launchBillingFlow( paramsBuilder.setSubscriptionUpdateParams(subscriptionUpdateParamsBuilder.build()); } result.success( - fromBillingResult( - billingClient.launchBillingFlow(activity, paramsBuilder.build()))); + fromBillingResult(billingClient.launchBillingFlow(activity, paramsBuilder.build()))); } private void consumeAsync(String purchaseToken, final MethodChannel.Result result) { diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 53e457274c73..9fe523257000 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -28,21 +28,29 @@ static HashMap fromProductDetail(ProductDetails detail) { info.put("productType", detail.getProductType()); info.put("name", detail.getName()); - @Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails = detail.getOneTimePurchaseOfferDetails(); + @Nullable + ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails = + detail.getOneTimePurchaseOfferDetails(); if (oneTimePurchaseOfferDetails != null) { - info.put("oneTimePurchaseOfferDetails", fromOneTimePurchaseOfferDetails(oneTimePurchaseOfferDetails)); + info.put( + "oneTimePurchaseOfferDetails", + fromOneTimePurchaseOfferDetails(oneTimePurchaseOfferDetails)); } - @Nullable List subscriptionOfferDetailsList = detail.getSubscriptionOfferDetails(); + @Nullable + List subscriptionOfferDetailsList = + detail.getSubscriptionOfferDetails(); if (subscriptionOfferDetailsList != null) { - info.put("subscriptionOfferDetails", fromSubscriptionOfferDetailsList(subscriptionOfferDetailsList)); + info.put( + "subscriptionOfferDetails", + fromSubscriptionOfferDetailsList(subscriptionOfferDetailsList)); } return info; } static List> fromProductDetailsList( - @Nullable List productDetailsList) { + @Nullable List productDetailsList) { if (productDetailsList == null) { return Collections.emptyList(); } @@ -54,7 +62,8 @@ static List> fromProductDetailsList( return output; } - static HashMap fromOneTimePurchaseOfferDetails(@Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails) { + static HashMap fromOneTimePurchaseOfferDetails( + @Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails) { HashMap serialized = new HashMap<>(); if (oneTimePurchaseOfferDetails == null) { return serialized; @@ -67,21 +76,24 @@ static HashMap fromOneTimePurchaseOfferDetails(@Nullable Product return serialized; } - static List> fromSubscriptionOfferDetailsList(@Nullable List subscriptionOfferDetailsList) { + static List> fromSubscriptionOfferDetailsList( + @Nullable List subscriptionOfferDetailsList) { if (subscriptionOfferDetailsList == null) { return Collections.emptyList(); } ArrayList> serialized = new ArrayList<>(); - for (ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails : subscriptionOfferDetailsList) { + for (ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails : + subscriptionOfferDetailsList) { serialized.add(fromSubscriptionOfferDetails(subscriptionOfferDetails)); } return serialized; } - static HashMap fromSubscriptionOfferDetails(@Nullable ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails) { + static HashMap fromSubscriptionOfferDetails( + @Nullable ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails) { HashMap serialized = new HashMap<>(); if (subscriptionOfferDetails == null) { return serialized; @@ -98,7 +110,8 @@ static HashMap fromSubscriptionOfferDetails(@Nullable ProductDet return serialized; } - static List> fromPricingPhases(@NonNull ProductDetails.PricingPhases pricingPhases) { + static List> fromPricingPhases( + @NonNull ProductDetails.PricingPhases pricingPhases) { ArrayList> serialized = new ArrayList<>(); for (ProductDetails.PricingPhase pricingPhase : pricingPhases.getPricingPhaseList()) { @@ -107,7 +120,8 @@ static List> fromPricingPhases(@NonNull ProductDetails.P return serialized; } - static HashMap fromPricingPhase(@Nullable ProductDetails.PricingPhase pricingPhase) { + static HashMap fromPricingPhase( + @Nullable ProductDetails.PricingPhase pricingPhase) { HashMap serialized = new HashMap<>(); if (pricingPhase == null) { diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index f14e233de5d9..be4d6b0c7763 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -63,7 +63,6 @@ import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.Result; - import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -73,7 +72,6 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; - import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -214,44 +212,45 @@ public void endConnection() { verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation); } - @Test - public void queryProductDetailsAsync() { - // Connect a billing client and set up the product query listeners - establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); - String productType = BillingClient.ProductType.INAPP; - List productsList = asList("id1", "id2"); - HashMap arguments = new HashMap<>(); - arguments.put("productTypes", Arrays.asList(productType, productType)); - arguments.put("productIds", productsList); - MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); - - // Query for product details - methodChannelHandler.onMethodCall(queryCall, result); - - // Assert the arguments were forwarded correctly to BillingClient - ArgumentCaptor paramCaptor = - ArgumentCaptor.forClass(QueryProductDetailsParams.class); - ArgumentCaptor listenerCaptor = - ArgumentCaptor.forClass(ProductDetailsResponseListener.class); - verify(mockBillingClient).queryProductDetailsAsync(paramCaptor.capture(), listenerCaptor.capture()); - - // Assert that we handed result BillingClient's response - int responseCode = 200; - List productDetailsResponse = - asList(buildProductDetails("foo")); - BillingResult billingResult = - BillingResult.newBuilder() - .setResponseCode(100) - .setDebugMessage("dummy debug message") - .build(); - listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); - @SuppressWarnings("unchecked") - ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); - verify(result).success(resultCaptor.capture()); - HashMap resultData = resultCaptor.getValue(); - assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); - assertEquals(resultData.get("productDetailsList"), fromProductDetailsList(productDetailsResponse)); - } + @Test + public void queryProductDetailsAsync() { + // Connect a billing client and set up the product query listeners + establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); + String productType = BillingClient.ProductType.INAPP; + List productsList = asList("id1", "id2"); + HashMap arguments = new HashMap<>(); + arguments.put("productTypes", Arrays.asList(productType, productType)); + arguments.put("productIds", productsList); + MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); + + // Query for product details + methodChannelHandler.onMethodCall(queryCall, result); + + // Assert the arguments were forwarded correctly to BillingClient + ArgumentCaptor paramCaptor = + ArgumentCaptor.forClass(QueryProductDetailsParams.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(ProductDetailsResponseListener.class); + verify(mockBillingClient) + .queryProductDetailsAsync(paramCaptor.capture(), listenerCaptor.capture()); + + // Assert that we handed result BillingClient's response + int responseCode = 200; + List productDetailsResponse = asList(buildProductDetails("foo")); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); + @SuppressWarnings("unchecked") + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(result).success(resultCaptor.capture()); + HashMap resultData = resultCaptor.getValue(); + assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); + assertEquals( + resultData.get("productDetailsList"), fromProductDetailsList(productDetailsResponse)); + } @Test public void queryProductDetailsAsync_clientDisconnected() { @@ -583,7 +582,8 @@ public void launchBillingFlow_oldProductNotFound() { methodChannelHandler.onMethodCall(launchCall, result); // Assert that we sent an error back. - verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_PRODUCT"), contains(oldProductId), any()); + verify(result) + .error(contains("IN_APP_PURCHASE_INVALID_OLD_PRODUCT"), contains(oldProductId), any()); verify(result, never()).success(any()); } @@ -862,8 +862,7 @@ private void queryForProducts(List productsList) { HashMap arguments = new HashMap<>(); String productType = BillingClient.ProductType.INAPP; List productTypes = new ArrayList<>(); - for (String ignored : - productsList) { + for (String ignored : productsList) { productTypes.add(productType); } arguments.put("productTypes", productTypes); @@ -888,38 +887,28 @@ private void queryForProducts(List productsList) { listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); } - private ProductDetails buildProductDetails(String id) { - String json = - String.format( - "{\n" + - " \"title\": \"Example title\",\n" + - " \"description\": \"Example description\",\n" + - " \"productId\": \"%s\",\n" + - " \"type\": \"inapp\",\n" + - " \"name\": \"Example name\",\n" + - " \"oneTimePurchaseOfferDetails\": {\n" + - " \"priceAmountMicros\": 990000,\n" + - " \"priceCurrencyCode\": \"USD\",\n" + - " \"formattedPrice\": \"$0.99\"\n" + - " }\n" + - "}", - id); - - try { - Constructor productDetailsConstructor = ProductDetails.class.getDeclaredConstructor(String.class); - productDetailsConstructor.setAccessible(true); - return productDetailsConstructor.newInstance(json); - } catch (NoSuchMethodException e) { - fail("buildProductDetails failed with NoSuchMethodException " + e); - } catch (InvocationTargetException e) { - fail("buildProductDetails failed with InvocationTargetException " + e); - } catch (IllegalAccessException e) { - fail("buildProductDetails failed with IllegalAccessException " + e); - } catch (InstantiationException e) { - fail("buildProductDetails failed with InstantiationException " + e); - } - return null; + private ProductDetails buildProductDetails(String id) { + String json = + String.format( + "{\"title\":\"Example title\",\"description\":\"Example description\",\"productId\":\"%s\",\"type\":\"inapp\",\"name\":\"Example name\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":990000,\"priceCurrencyCode\":\"USD\",\"formattedPrice\":\"$0.99\"}}", + id); + + try { + Constructor productDetailsConstructor = + ProductDetails.class.getDeclaredConstructor(String.class); + productDetailsConstructor.setAccessible(true); + return productDetailsConstructor.newInstance(json); + } catch (NoSuchMethodException e) { + fail("buildProductDetails failed with NoSuchMethodException " + e); + } catch (InvocationTargetException e) { + fail("buildProductDetails failed with InvocationTargetException " + e); + } catch (IllegalAccessException e) { + fail("buildProductDetails failed with IllegalAccessException " + e); + } catch (InstantiationException e) { + fail("buildProductDetails failed with InstantiationException " + e); } + return null; + } private Purchase buildPurchase(String orderId) { Purchase purchase = mock(Purchase.class); diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index 4561f45a3577..aa32afe2e43c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -17,7 +17,6 @@ import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; - import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.Arrays; @@ -33,8 +32,10 @@ public class TranslatorTest { private static final String PURCHASE_EXAMPLE_JSON = "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\", \"obfuscatedAccountId\":\"Account101\", \"obfuscatedProfileId\":\"Profile105\"}"; - private static final String IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON = "{\"title\":\"Example title\",\"description\":\"Example description\",\"productId\":\"Example id\",\"type\":\"inapp\",\"name\":\"Example name\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":990000,\"priceCurrencyCode\":\"USD\",\"formattedPrice\":\"$0.99\"}}"; - private static final String SUBS_PRODUCT_DETAIL_EXAMPLE_JSON = "{\"title\":\"Example title 2\",\"description\":\"Example description 2\",\"productId\":\"Example id 2\",\"type\":\"subs\",\"name\":\"Example name 2\",\"subscriptionOfferDetails\":[{\"offerId\":\"Example offer id\",\"basePlanId\":\"Example base plan id\",\"offerTags\":[\"Example offer tag\"],\"offerIdToken\":\"Example offer token\",\"pricingPhases\":[{\"formattedPrice\":\"$0.99\",\"priceCurrencyCode\":\"USD\",\"priceAmountMicros\":990000,\"billingCycleCount\":4,\"billingPeriod\":\"Example billing period\",\"recurrenceMode\":0}]}]}"; + private static final String IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON = + "{\"title\":\"Example title\",\"description\":\"Example description\",\"productId\":\"Example id\",\"type\":\"inapp\",\"name\":\"Example name\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":990000,\"priceCurrencyCode\":\"USD\",\"formattedPrice\":\"$0.99\"}}"; + private static final String SUBS_PRODUCT_DETAIL_EXAMPLE_JSON = + "{\"title\":\"Example title 2\",\"description\":\"Example description 2\",\"productId\":\"Example id 2\",\"type\":\"subs\",\"name\":\"Example name 2\",\"subscriptionOfferDetails\":[{\"offerId\":\"Example offer id\",\"basePlanId\":\"Example base plan id\",\"offerTags\":[\"Example offer tag\"],\"offerIdToken\":\"Example offer token\",\"pricingPhases\":[{\"formattedPrice\":\"$0.99\",\"priceCurrencyCode\":\"USD\",\"priceAmountMicros\":990000,\"billingCycleCount\":4,\"billingPeriod\":\"Example billing period\",\"recurrenceMode\":0}]}]}"; Constructor productDetailsConstructor; @@ -48,8 +49,10 @@ public void setup() throws NoSuchMethodException { } @Test - public void fromInAppProductDetail() throws InvocationTargetException, IllegalAccessException, InstantiationException { - final ProductDetails expected = productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON); + public void fromInAppProductDetail() + throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = + productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON); Map serialized = Translator.fromProductDetail(expected); @@ -57,8 +60,10 @@ public void fromInAppProductDetail() throws InvocationTargetException, IllegalAc } @Test - public void fromSubsProductDetail() throws InvocationTargetException, IllegalAccessException, InstantiationException { - final ProductDetails expected = productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON); + public void fromSubsProductDetail() + throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = + productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON); Map serialized = Translator.fromProductDetail(expected); @@ -66,11 +71,12 @@ public void fromSubsProductDetail() throws InvocationTargetException, IllegalAcc } @Test - public void fromProductDetailsList() throws InvocationTargetException, IllegalAccessException, InstantiationException { + public void fromProductDetailsList() + throws InvocationTargetException, IllegalAccessException, InstantiationException { final List expected = Arrays.asList( - productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON), - productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON)); + productDetailsConstructor.newInstance(IN_APP_PRODUCT_DETAIL_EXAMPLE_JSON), + productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON)); final List> serialized = Translator.fromProductDetailsList(expected); @@ -185,74 +191,82 @@ public void currencyCodeFromSymbol() { } } - private void assertSerialized( - ProductDetails expected, Map serialized) { + private void assertSerialized(ProductDetails expected, Map serialized) { assertEquals(expected.getDescription(), serialized.get("description")); assertEquals(expected.getTitle(), serialized.get("title")); assertEquals(expected.getName(), serialized.get("name")); - assertEquals( - expected.getProductId(), - serialized.get("productId")); + assertEquals(expected.getProductId(), serialized.get("productId")); assertEquals(expected.getProductType(), serialized.get("productType")); - ProductDetails.OneTimePurchaseOfferDetails expectedOneTimePurchaseOfferDetails = expected.getOneTimePurchaseOfferDetails(); + ProductDetails.OneTimePurchaseOfferDetails expectedOneTimePurchaseOfferDetails = + expected.getOneTimePurchaseOfferDetails(); Object oneTimePurchaseOfferDetailsObject = serialized.get("oneTimePurchaseOfferDetails"); - assertEquals(expectedOneTimePurchaseOfferDetails == null, oneTimePurchaseOfferDetailsObject == null); + assertEquals( + expectedOneTimePurchaseOfferDetails == null, oneTimePurchaseOfferDetailsObject == null); if (expectedOneTimePurchaseOfferDetails != null && oneTimePurchaseOfferDetailsObject != null) { - @SuppressWarnings(value="unchecked") - Map oneTimePurchaseOfferDetailsMap = (Map) oneTimePurchaseOfferDetailsObject; + @SuppressWarnings(value = "unchecked") + Map oneTimePurchaseOfferDetailsMap = + (Map) oneTimePurchaseOfferDetailsObject; assertSerialized(expectedOneTimePurchaseOfferDetails, oneTimePurchaseOfferDetailsMap); } - List expectedSubscriptionOfferDetailsList = expected.getSubscriptionOfferDetails(); + List expectedSubscriptionOfferDetailsList = + expected.getSubscriptionOfferDetails(); Object subscriptionOfferDetailsListObject = serialized.get("subscriptionOfferDetails"); - assertEquals(expectedSubscriptionOfferDetailsList == null, subscriptionOfferDetailsListObject == null); - if (expectedSubscriptionOfferDetailsList != null && subscriptionOfferDetailsListObject != null) { - @SuppressWarnings(value="unchecked") - List subscriptionOfferDetailsListList = (List) subscriptionOfferDetailsListObject; + assertEquals( + expectedSubscriptionOfferDetailsList == null, subscriptionOfferDetailsListObject == null); + if (expectedSubscriptionOfferDetailsList != null + && subscriptionOfferDetailsListObject != null) { + @SuppressWarnings(value = "unchecked") + List subscriptionOfferDetailsListList = + (List) subscriptionOfferDetailsListObject; assertSerialized(expectedSubscriptionOfferDetailsList, subscriptionOfferDetailsListList); } } - private void assertSerialized(ProductDetails.OneTimePurchaseOfferDetails expected, Map serialized) { - assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); - assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); - assertEquals(expected.getFormattedPrice(), serialized.get("formattedPrice")); + private void assertSerialized( + ProductDetails.OneTimePurchaseOfferDetails expected, Map serialized) { + assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); + assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals(expected.getFormattedPrice(), serialized.get("formattedPrice")); } - private void assertSerialized(List expected, List serialized) { + private void assertSerialized( + List expected, List serialized) { assertEquals(expected.size(), serialized.size()); for (int i = 0; i < expected.size(); i++) { - @SuppressWarnings(value="unchecked") + @SuppressWarnings(value = "unchecked") Map serializedMap = (Map) serialized.get(i); assertSerialized(expected.get(i), serializedMap); } } - private void assertSerialized(ProductDetails.SubscriptionOfferDetails expected, Map serialized) { - assertEquals(expected.getBasePlanId(), serialized.get("basePlanId")); - assertEquals(expected.getOfferId(), serialized.get("offerId")); - assertEquals(expected.getOfferTags(), serialized.get("offerTags")); - assertEquals(expected.getOfferToken(), serialized.get("offerIdToken")); - - @SuppressWarnings(value="unchecked") - List serializedPricingPhases = (List) serialized.get("pricingPhases"); - assertNotNull(serializedPricingPhases); - assertSerialized(expected.getPricingPhases(), serializedPricingPhases); + private void assertSerialized( + ProductDetails.SubscriptionOfferDetails expected, Map serialized) { + assertEquals(expected.getBasePlanId(), serialized.get("basePlanId")); + assertEquals(expected.getOfferId(), serialized.get("offerId")); + assertEquals(expected.getOfferTags(), serialized.get("offerTags")); + assertEquals(expected.getOfferToken(), serialized.get("offerIdToken")); + + @SuppressWarnings(value = "unchecked") + List serializedPricingPhases = (List) serialized.get("pricingPhases"); + assertNotNull(serializedPricingPhases); + assertSerialized(expected.getPricingPhases(), serializedPricingPhases); } private void assertSerialized(ProductDetails.PricingPhases expected, List serialized) { List expectedPhases = expected.getPricingPhaseList(); assertEquals(expectedPhases.size(), serialized.size()); for (int i = 0; i < serialized.size(); i++) { - @SuppressWarnings(value="unchecked") + @SuppressWarnings(value = "unchecked") Map pricingPhaseMap = (Map) serialized.get(i); assertSerialized(expectedPhases.get(i), pricingPhaseMap); } expected.getPricingPhaseList(); } - private void assertSerialized(ProductDetails.PricingPhase expected, Map serialized) { + private void assertSerialized( + ProductDetails.PricingPhase expected, Map serialized) { assertEquals(expected.getFormattedPrice(), serialized.get("formattedPrice")); assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); From ca2a8d615ef776e4c8ca78d645eadee97f8c7caf Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 15:00:45 +0200 Subject: [PATCH 05/17] Serialize `ProductWrapper` properly --- .../inapppurchase/MethodCallHandlerImpl.java | 20 ++------- .../plugins/inapppurchase/Translator.java | 24 +++++++++- .../inapppurchase/MethodCallHandlerTest.java | 29 +++++++----- .../billing_client_wrapper.dart | 8 ++-- .../product_wrapper.dart | 44 ++++++++++++++++--- .../product_wrapper.g.dart | 23 ++++++++++ .../src/in_app_purchase_android_platform.dart | 8 ++-- .../billing_client_wrapper_test.dart | 15 ++++--- .../product_wrapper_test.dart | 30 +++++++++++++ 9 files changed, 150 insertions(+), 51 deletions(-) create mode 100644 packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.g.dart create mode 100644 packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_wrapper_test.dart diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index abf68b3a863e..c5abc0d967f1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -8,6 +8,7 @@ import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; +import static io.flutter.plugins.inapppurchase.Translator.toProductList; import android.app.Activity; import android.app.Application; @@ -123,9 +124,8 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { endConnection(result); break; case InAppPurchasePlugin.MethodNames.QUERY_PRODUCT_DETAILS: - List productIds = call.argument("productIds"); - List productTypes = call.argument("productTypes"); - queryProductDetailsAsync(productIds, productTypes, result); + List productList = toProductList(call.argument("productList")); + queryProductDetailsAsync(productList, result); break; case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW: launchBillingFlow( @@ -184,24 +184,12 @@ private void isReady(MethodChannel.Result result) { } private void queryProductDetailsAsync( - final List productIds, - final List productTypes, + final List productList, final MethodChannel.Result result) { - assert (productIds.size() == productTypes.size()); - if (billingClientError(result)) { return; } - List productList = new ArrayList<>(); - for (int i = 0; i < productIds.size(); i++) { - productList.add( - Product.newBuilder() - .setProductId(productIds.get(i)) - .setProductType(productTypes.get(i)) - .build()); - } - QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder().setProductList(productList).build(); billingClient.queryProductDetailsAsync( diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 9fe523257000..30b19948530b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -7,18 +7,23 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.AccountIdentifiers; +import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; +import com.android.billingclient.api.QueryProductDetailsParams; + import java.util.ArrayList; import java.util.Collections; import java.util.Currency; import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; -/** Handles serialization of {@link com.android.billingclient.api.BillingClient} related objects. */ +/** Handles serialization and deserialization of {@link com.android.billingclient.api.BillingClient} + * related objects. */ /*package*/ class Translator { static HashMap fromProductDetail(ProductDetails detail) { HashMap info = new HashMap<>(); @@ -49,6 +54,23 @@ static HashMap fromProductDetail(ProductDetails detail) { return info; } + static List toProductList(List serialized) { + List products = new ArrayList<>(); + for (Object productSerialized : + serialized) { + @SuppressWarnings(value="unchecked") + Map productMap = (Map) productSerialized; + products.add(toProduct(productMap)); + } + return products; + } + + static QueryProductDetailsParams.Product toProduct(Map serialized) { + String productId = (String) serialized.get("productId"); + String productType = (String) serialized.get("productType"); + return QueryProductDetailsParams.Product.newBuilder().setProductId(productId).setProductType(productType).build(); + } + static List> fromProductDetailsList( @Nullable List productDetailsList) { if (productDetailsList == null) { diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index be4d6b0c7763..9dd82d96afae 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -219,8 +219,7 @@ public void queryProductDetailsAsync() { String productType = BillingClient.ProductType.INAPP; List productsList = asList("id1", "id2"); HashMap arguments = new HashMap<>(); - arguments.put("productTypes", Arrays.asList(productType, productType)); - arguments.put("productIds", productsList); + arguments.put("productList", buildProductMap(productsList, productType)); MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); // Query for product details @@ -260,8 +259,7 @@ public void queryProductDetailsAsync_clientDisconnected() { String productType = BillingClient.ProductType.INAPP; List productsList = asList("id1", "id2"); HashMap arguments = new HashMap<>(); - arguments.put("productTypes", Arrays.asList(productType, productType)); - arguments.put("productIds", productsList); + arguments.put("productList", buildProductMap(productsList, productType)); MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); // Query for product details @@ -856,17 +854,13 @@ private void establishConnectedBillingClient( methodChannelHandler.onMethodCall(connectCall, result); } - private void queryForProducts(List productsList) { + private void queryForProducts(List productIdList) { // Set up the query method call establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); HashMap arguments = new HashMap<>(); String productType = BillingClient.ProductType.INAPP; - List productTypes = new ArrayList<>(); - for (String ignored : productsList) { - productTypes.add(productType); - } - arguments.put("productTypes", productTypes); - arguments.put("productIds", productsList); + List> productList = buildProductMap(productIdList, productType); + arguments.put("productList", productList); MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); // Call the method. @@ -877,7 +871,7 @@ private void queryForProducts(List productsList) { ArgumentCaptor.forClass(ProductDetailsResponseListener.class); verify(mockBillingClient).queryProductDetailsAsync(any(), listenerCaptor.capture()); List productDetailsResponse = - productsList.stream().map(this::buildProductDetails).collect(toList()); + productIdList.stream().map(this::buildProductDetails).collect(toList()); BillingResult billingResult = BillingResult.newBuilder() @@ -887,6 +881,17 @@ private void queryForProducts(List productsList) { listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); } + private List> buildProductMap(List productIds, String productType) { + List> productList = new ArrayList<>(); + for (String productId : productIds) { + Map productMap = new HashMap<>(); + productMap.put("productId", productId); + productMap.put("productType", productType); + productList.add(productMap); + } + return productList; + } + private ProductDetails buildProductDetails(String id) { String json = String.format( diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index 896f81da5433..cf8e0f772362 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -144,13 +144,11 @@ class BillingClient { /// `ProductDetailsParams` as direct arguments instead of requiring it /// constructed and passed in as a class. Future queryProductDetails({ - required List productList, + required List productList, }) async { final Map arguments = { - 'productIds': productList.map((Product p) => p.id).toList(), - 'productTypes': productList - .map((Product p) => const ProductTypeConverter().toJson(p.type)) - .toList(), + 'productList': + productList.map((ProductWrapper product) => product.toJson()).toList() }; return ProductDetailsResponseWrapper.fromJson( (await channel.invokeMapMethod( diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart index 154dde6f4617..7988e545eb1a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart @@ -1,16 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + import '../../billing_client_wrappers.dart'; +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'product_wrapper.g.dart'; + /// Dart wrapper around [`com.android.billingclient.api.Product`](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). -class Product { - /// Creates a new [Product]. - const Product({ - required this.id, - required this.type, +@JsonSerializable(createToJson: true) +@immutable +class ProductWrapper { + /// Creates a new [ProductWrapper]. + const ProductWrapper({ + required this.productId, + required this.productType, }); + /// Creates a JSON representation of this product. + Map toJson() => _$ProductWrapperToJson(this); + /// The product identifier. - final String id; + @JsonKey(defaultValue: '') + final String productId; /// The product type. - final ProductType type; + final ProductType productType; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is ProductWrapper && + other.productId == productId && + other.productType == productType; + } + + @override + int get hashCode => Object.hash(productId, productType); } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.g.dart new file mode 100644 index 000000000000..c3ba8f4a82ec --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'product_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProductWrapper _$ProductWrapperFromJson(Map json) => ProductWrapper( + productId: json['productId'] as String? ?? '', + productType: $enumDecode(_$ProductTypeEnumMap, json['productType']), + ); + +Map _$ProductWrapperToJson(ProductWrapper instance) => + { + 'productId': instance.productId, + 'productType': _$ProductTypeEnumMap[instance.productType]!, + }; + +const _$ProductTypeEnumMap = { + ProductType.inapp: 'inapp', + ProductType.subs: 'subs', +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index ab1979d8744f..4ef8919ab86b 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -80,16 +80,16 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { billingClientManager.runWithClient( (BillingClient client) => client.queryProductDetails( productList: identifiers - .map((String productId) => - Product(id: productId, type: ProductType.subs)) + .map((String productId) => ProductWrapper( + productId: productId, productType: ProductType.subs)) .toList(), ), ), billingClientManager.runWithClient( (BillingClient client) => client.queryProductDetails( productList: identifiers - .map((String productId) => - Product(id: productId, type: ProductType.inapp)) + .map((String productId) => ProductWrapper( + productId: productId, productType: ProductType.inapp)) .toList(), ), ), diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 0e34ee84c6dc..0c21a17ef58c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -131,8 +131,9 @@ void main() { }); final ProductDetailsResponseWrapper response = await billingClient - .queryProductDetails(productList: [ - const Product(id: 'invalid', type: ProductType.inapp) + .queryProductDetails(productList: [ + const ProductWrapper( + productId: 'invalid', productType: ProductType.inapp) ]); const BillingResultWrapper billingResult = BillingResultWrapper( @@ -156,8 +157,9 @@ void main() { final ProductDetailsResponseWrapper response = await billingClient.queryProductDetails( - productList: [ - const Product(id: 'invalid', type: ProductType.inapp), + productList: [ + const ProductWrapper( + productId: 'invalid', productType: ProductType.inapp), ], ); @@ -172,8 +174,9 @@ void main() { final ProductDetailsResponseWrapper response = await billingClient.queryProductDetails( - productList: [ - const Product(id: 'invalid', type: ProductType.inapp), + productList: [ + const ProductWrapper( + productId: 'invalid', productType: ProductType.inapp), ], ); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_wrapper_test.dart new file mode 100644 index 000000000000..d9fe397b525d --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_wrapper_test.dart @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:test/test.dart'; + +const ProductWrapper dummyProduct = ProductWrapper( + productId: 'id', + productType: ProductType.inapp, +); + +void main() { + group('ProductWrapper', () { + test('converts product from map', () { + const ProductWrapper expected = dummyProduct; + final ProductWrapper parsed = productFromJson(expected.toJson()); + + expect(parsed, equals(expected)); + }); + }); +} + +ProductWrapper productFromJson(Map serialized) { + return ProductWrapper( + productId: serialized['productId'] as String, + productType: const ProductTypeConverter() + .fromJson(serialized['productType'] as String), + ); +} From f6a1e73e3a49c07a14634809a9beb8ec3c51c21a Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 15:29:52 +0200 Subject: [PATCH 06/17] Update documentation --- .../types/google_play_product_details.dart | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 7fb28b161bf8..71486205d3a7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -11,7 +11,7 @@ import '../../billing_client_wrappers.dart'; class GooglePlayProductDetails extends ProductDetails { /// Creates a new Google Play specific product details object with the /// provided details. - GooglePlayProductDetails({ + GooglePlayProductDetails._({ required super.id, required super.title, required super.description, @@ -41,7 +41,7 @@ class GooglePlayProductDetails extends ProductDetails { final String currencySymbol = formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; - return GooglePlayProductDetails( + return GooglePlayProductDetails._( id: productDetails.productId, title: productDetails.title, description: productDetails.description, @@ -55,6 +55,14 @@ class GooglePlayProductDetails extends ProductDetails { /// Generate a [GooglePlayProductDetails] object based on an Android /// [ProductDetailsWrapper] object for a subscription product. + /// Subscriptions can consist of multiple base plans, and base plans in turn + /// can consist of multiple offers. This method generates a list where every + /// element corresponds to a base plan or its offer. + /// + /// Subscriptions can consist of multiple base plans, and base plans in turn + /// can consist of multiple offers. [subscriptionIndex] points to the index of + /// [productDetails.subscriptionOfferDetails] for which the + /// [GooglePlayProductDetails] is constructed. factory GooglePlayProductDetails._fromSubscription( ProductDetailsWrapper productDetails, int subscriptionIndex, @@ -74,7 +82,7 @@ class GooglePlayProductDetails extends ProductDetails { final String currencySymbol = formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; - return GooglePlayProductDetails( + return GooglePlayProductDetails._( id: productDetails.productId, title: productDetails.title, description: productDetails.description, @@ -88,11 +96,13 @@ class GooglePlayProductDetails extends ProductDetails { } /// Generate a list of [GooglePlayProductDetails] based on an Android - /// [ProductDetailsWrapper] object for a subscription product. + /// [ProductDetailsWrapper] object. /// - /// Subscriptions can consist of multiple base plans, and base plans in turn - /// can consist of multiple offers. This method generates a list where every - /// element corresponds to a base plan or its offer. + /// If [productDetails] is of type [ProductType.inapp], a single + /// [GooglePlayProductDetails] will be constructed. + /// If [productDetails] is of type [ProductType.subs], a list is returned + /// where every element corresponds to a base plan or its offer in + /// [productDetails.subscriptionOfferDetails]. static List fromProductDetails( ProductDetailsWrapper productDetails, ) { @@ -126,7 +136,7 @@ class GooglePlayProductDetails extends ProductDetails { final int? subscriptionIndex; /// The offerToken of the subscription this [GooglePlayProductDetails] - /// object was contructed for, or 'null' if it was not a subscription. + /// object was contructed for, or `null` if it was not a subscription. String? get offerToken => subscriptionIndex != null && productDetails.subscriptionOfferDetails != null ? productDetails From eead5cb48960d6fc69933e838330b6d3859f7228 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 15:30:22 +0200 Subject: [PATCH 07/17] Remove useless branching --- .../src/in_app_purchase_android_platform.dart | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 4ef8919ab86b..6fdaff557c39 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -146,34 +146,17 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { (purchaseParam.productDetails as GooglePlayProductDetails).offerToken; } - final bool isSupported = - await billingClientManager.runWithClientNonRetryable( - (BillingClient client) => - client.isFeatureSupported(BillingClientFeature.productDetails), + final BillingResultWrapper billingResultWrapper = + await billingClientManager.runWithClient( + (BillingClient client) => client.launchBillingFlow( + product: purchaseParam.productDetails.id, + offerToken: offerToken, + accountId: purchaseParam.applicationUserName, + oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode), ); - final BillingResultWrapper billingResultWrapper; - if (isSupported) { - billingResultWrapper = await billingClientManager.runWithClient( - (BillingClient client) => client.launchBillingFlow( - product: purchaseParam.productDetails.id, - offerToken: offerToken, - accountId: purchaseParam.applicationUserName, - oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: changeSubscriptionParam - ?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: changeSubscriptionParam?.prorationMode), - ); - } else { - billingResultWrapper = await billingClientManager.runWithClient( - (BillingClient client) => client.launchBillingFlow( - product: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName, - oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, - purchaseToken: changeSubscriptionParam - ?.oldPurchaseDetails.verificationData.serverVerificationData, - prorationMode: changeSubscriptionParam?.prorationMode), - ); - } return billingResultWrapper.responseCode == BillingResponse.ok; } From 1815aa633bdf800d966d00ee04fe5f8c7319162e Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 15:48:47 +0200 Subject: [PATCH 08/17] Update CHANGELOG and version number --- packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md | 4 ++++ packages/in_app_purchase/in_app_purchase_android/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index cc8101dc4f27..e43e181a3289 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.0 +* **BREAKING CHANGE**: Removes `launchPriceChangeConfirmationFlow` from `InAppPurchaseAndroidPlatform` as it is deprecated by Android. +* Returns both base plans and offers when `queryProductDetailsAsync` is called. + ## 0.2.5+5 * Updates gradle, AGP and fixes some lint errors. diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 54f686c61b23..08e5f4d31192 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_android description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.2.5+5 +version: 0.3.0 environment: sdk: ">=2.18.0 <4.0.0" From 21ba93f7bfc22bb09d22384b59540ec494cdb375 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 15:50:13 +0200 Subject: [PATCH 09/17] Format Java files --- .../inapppurchase/MethodCallHandlerImpl.java | 3 +-- .../plugins/inapppurchase/Translator.java | 18 ++++++++++-------- .../inapppurchase/MethodCallHandlerTest.java | 1 - 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index c5abc0d967f1..5b4b023cb531 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -184,8 +184,7 @@ private void isReady(MethodChannel.Result result) { } private void queryProductDetailsAsync( - final List productList, - final MethodChannel.Result result) { + final List productList, final MethodChannel.Result result) { if (billingClientError(result)) { return; } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 30b19948530b..9f397e4e9fb6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -7,13 +7,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.billingclient.api.AccountIdentifiers; -import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.QueryProductDetailsParams; - import java.util.ArrayList; import java.util.Collections; import java.util.Currency; @@ -22,8 +20,10 @@ import java.util.Locale; import java.util.Map; -/** Handles serialization and deserialization of {@link com.android.billingclient.api.BillingClient} - * related objects. */ +/** + * Handles serialization and deserialization of {@link com.android.billingclient.api.BillingClient} + * related objects. + */ /*package*/ class Translator { static HashMap fromProductDetail(ProductDetails detail) { HashMap info = new HashMap<>(); @@ -56,9 +56,8 @@ static HashMap fromProductDetail(ProductDetails detail) { static List toProductList(List serialized) { List products = new ArrayList<>(); - for (Object productSerialized : - serialized) { - @SuppressWarnings(value="unchecked") + for (Object productSerialized : serialized) { + @SuppressWarnings(value = "unchecked") Map productMap = (Map) productSerialized; products.add(toProduct(productMap)); } @@ -68,7 +67,10 @@ static List toProductList(List serial static QueryProductDetailsParams.Product toProduct(Map serialized) { String productId = (String) serialized.get("productId"); String productType = (String) serialized.get("productType"); - return QueryProductDetailsParams.Product.newBuilder().setProductId(productId).setProductType(productType).build(); + return QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(productType) + .build(); } static List> fromProductDetailsList( diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 9dd82d96afae..585ed646b441 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -66,7 +66,6 @@ import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; From 7c2ae4ccf0ccf80d13927893dfe55ee631a7611b Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 16:32:03 +0200 Subject: [PATCH 10/17] Add missing license blocks --- .../src/billing_client_wrappers/billing_response_wrapper.dart | 4 ++++ .../lib/src/billing_client_wrappers/product_wrapper.dart | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart index da4f2a814797..6643362257a1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart @@ -1,3 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart index 7988e545eb1a..48cd9ee738ec 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart @@ -1,3 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; From 7c6d121bfa12cb4a820f5e11bf27026264191feb Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Tue, 18 Apr 2023 16:53:05 +0200 Subject: [PATCH 11/17] Fix issues reported by Cirrus --- .../flutter/plugins/inapppurchase/MethodCallHandlerImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 5b4b023cb531..be910d0a728e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -238,7 +238,7 @@ private void launchBillingFlow( if (subscriptionOfferDetails != null) { boolean isValidOfferToken = false; for (ProductDetails.SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) { - if (Objects.equals(offerDetails.getOfferToken(), offerToken)) { + if (offerToken != null && offerToken.equals(offerDetails.getOfferToken())) { isValidOfferToken = true; break; } @@ -433,7 +433,7 @@ public void onAcknowledgePurchaseResponse(BillingResult billingResult) { }); } - private void updateCachedProducts(@Nullable List productDetailsList) { + protected void updateCachedProducts(@Nullable List productDetailsList) { if (productDetailsList == null) { return; } From 43925768a9af7ed452a0f400fcabd44ffc75c4af Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Wed, 19 Apr 2023 09:55:24 +0200 Subject: [PATCH 12/17] Remove unused import --- .../io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index be910d0a728e..38c6033594cb 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -42,7 +42,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; /** Handles method channel for the plugin. */ class MethodCallHandlerImpl From d164899172db10cb10d0e8d0517c7b3a3dd28e1f Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Mon, 1 May 2023 17:41:03 +0200 Subject: [PATCH 13/17] Incorporate feedback --- .../in_app_purchase_android/CHANGELOG.md | 2 +- .../billing_response_wrapper.dart | 2 +- .../src/in_app_purchase_android_platform.dart | 31 +++++----- ...pp_purchase_android_platform_addition.dart | 11 ++-- .../types/google_play_product_details.dart | 46 ++++++++++----- .../types/google_play_purchase_details.dart | 50 +++++++++------- .../purchase_wrapper_test.dart | 57 ++++++++++++++----- ...rchase_android_platform_addition_test.dart | 2 +- ...in_app_purchase_android_platform_test.dart | 11 ++-- 9 files changed, 131 insertions(+), 81 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index e43e181a3289..bf08b2db0ae8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,5 +1,5 @@ ## 0.3.0 -* **BREAKING CHANGE**: Removes `launchPriceChangeConfirmationFlow` from `InAppPurchaseAndroidPlatform` as it is deprecated by Android. +* **BREAKING CHANGE**: Removes `launchPriceChangeConfirmationFlow` from `InAppPurchaseAndroidPlatform`. Price changes are now [handled by Google Play](https://developer.android.com/google/play/billing/subscriptions#price-change). * Returns both base plans and offers when `queryProductDetailsAsync` is called. ## 0.2.5+5 diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart index 6643362257a1..62887b00d43c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart @@ -14,7 +14,7 @@ part 'billing_response_wrapper.g.dart'; /// The error message shown when the map represents billing result is invalid from method channel. /// -/// This usually indicates a series underlining code issue in the plugin. +/// This usually indicates a serious underlining code issue in the plugin. @visibleForTesting const String kInvalidBillingResultErrorMessage = 'Invalid billing result map from method channel.'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 6fdaff557c39..34e844c947b6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -220,17 +220,13 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { final String errorMessage = errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; - final List pastPurchases = - responses.expand((PurchasesResultWrapper response) { - return response.purchasesList; - }).map((PurchaseWrapper purchaseWrapper) { - final GooglePlayPurchaseDetails purchaseDetails = - GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); - - purchaseDetails.status = PurchaseStatus.restored; - - return purchaseDetails; - }).toList(); + final List pastPurchases = responses + .expand((PurchasesResultWrapper response) => response.purchasesList) + .expand((PurchaseWrapper purchaseWrapper) => + GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper)) + .map((GooglePlayPurchaseDetails details) => + details..status = PurchaseStatus.restored) + .toList(); if (errorMessage.isNotEmpty) { throw InAppPurchaseException( @@ -280,14 +276,15 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { details: resultWrapper.billingResult.debugMessage, ); } - final List> purchases = - resultWrapper.purchasesList.map((PurchaseWrapper purchase) { - final GooglePlayPurchaseDetails googlePlayPurchaseDetails = - GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error; + final List> purchases = resultWrapper.purchasesList + .expand((PurchaseWrapper purchase) => + GooglePlayPurchaseDetails.fromPurchase(purchase)) + .map((GooglePlayPurchaseDetails purchaseDetails) { + purchaseDetails.error = error; if (resultWrapper.responseCode == BillingResponse.userCanceled) { - googlePlayPurchaseDetails.status = PurchaseStatus.canceled; + purchaseDetails.status = PurchaseStatus.canceled; } - return _maybeAutoConsumePurchase(googlePlayPurchaseDetails); + return _maybeAutoConsumePurchase(purchaseDetails); }).toList(); if (purchases.isNotEmpty) { return Future.wait(purchases); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart index bc0c6174f391..3917ac6f264f 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -120,12 +120,11 @@ class InAppPurchaseAndroidPlatformAddition final String errorMessage = errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; - final List pastPurchases = - responses.expand((PurchasesResultWrapper response) { - return response.purchasesList; - }).map((PurchaseWrapper purchaseWrapper) { - return GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); - }).toList(); + final List pastPurchases = responses + .expand((PurchasesResultWrapper response) => response.purchasesList) + .expand((PurchaseWrapper purchaseWrapper) => + GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper)) + .toList(); IAPError? error; if (exception != null) { diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart index 71486205d3a7..0d3beaea513a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart @@ -23,7 +23,7 @@ class GooglePlayProductDetails extends ProductDetails { this.subscriptionIndex, }); - /// Generate a [GooglePlayProductDetails] object based on an Android + /// Generates a [GooglePlayProductDetails] object based on an Android /// [ProductDetailsWrapper] object for an in-app product. factory GooglePlayProductDetails._fromOneTimePurchaseProductDetails( ProductDetailsWrapper productDetails, @@ -38,8 +38,7 @@ class GooglePlayProductDetails extends ProductDetails { final double rawPrice = oneTimePurchaseOfferDetails.priceAmountMicros / 1000000.0; final String currencyCode = oneTimePurchaseOfferDetails.priceCurrencyCode; - final String currencySymbol = - formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; + final String? currencySymbol = _extractCurrencySymbol(formattedPrice); return GooglePlayProductDetails._( id: productDetails.productId, @@ -48,16 +47,13 @@ class GooglePlayProductDetails extends ProductDetails { price: formattedPrice, rawPrice: rawPrice, currencyCode: currencyCode, - currencySymbol: currencySymbol, + currencySymbol: currencySymbol ?? currencyCode, productDetails: productDetails, ); } - /// Generate a [GooglePlayProductDetails] object based on an Android + /// Generates a [GooglePlayProductDetails] object based on an Android /// [ProductDetailsWrapper] object for a subscription product. - /// Subscriptions can consist of multiple base plans, and base plans in turn - /// can consist of multiple offers. This method generates a list where every - /// element corresponds to a base plan or its offer. /// /// Subscriptions can consist of multiple base plans, and base plans in turn /// can consist of multiple offers. [subscriptionIndex] points to the index of @@ -77,10 +73,9 @@ class GooglePlayProductDetails extends ProductDetails { final PricingPhaseWrapper firstPricingPhase = subscriptionOfferDetails.pricingPhases.first; final String formattedPrice = firstPricingPhase.formattedPrice; - final double rawPrice = (firstPricingPhase.priceAmountMicros) / 1000000.0; + final double rawPrice = firstPricingPhase.priceAmountMicros / 1000000.0; final String currencyCode = firstPricingPhase.priceCurrencyCode; - final String currencySymbol = - formattedPrice.isEmpty ? currencyCode : formattedPrice[0]; + final String? currencySymbol = _extractCurrencySymbol(formattedPrice); return GooglePlayProductDetails._( id: productDetails.productId, @@ -89,13 +84,13 @@ class GooglePlayProductDetails extends ProductDetails { price: formattedPrice, rawPrice: rawPrice, currencyCode: currencyCode, - currencySymbol: currencySymbol, + currencySymbol: currencySymbol ?? currencyCode, productDetails: productDetails, subscriptionIndex: subscriptionIndex, ); } - /// Generate a list of [GooglePlayProductDetails] based on an Android + /// Generates a list of [GooglePlayProductDetails] based on an Android /// [ProductDetailsWrapper] object. /// /// If [productDetails] is of type [ProductType.inapp], a single @@ -127,12 +122,33 @@ class GooglePlayProductDetails extends ProductDetails { } } + /// Extracts the currency symbol from [formattedPrice]. + /// + /// Note that a currency symbol might consist of more than a single character. + /// + /// Just in case, we assume currency symbols can appear at the start or the + /// end of [formattedPrice]. + /// + /// The regex captures the characters from the start/end of the [String] + /// until the first/last digit or space. + static String? _extractCurrencySymbol(String formattedPrice) { + return RegExp(r'^[^\d ]*|[^\d ]*$').firstMatch(formattedPrice)?.group(0); + } + /// Points back to the [ProductDetailsWrapper] object that was used to /// generate this [GooglePlayProductDetails] object. final ProductDetailsWrapper productDetails; - /// The index pointing to the subscription this [GooglePlayProductDetails] - /// object was contructed for, or `null` if it was not a subscription. + /// The index pointing to the [SubscriptionOfferDetailsWrapper] this + /// [GooglePlayProductDetails] object was contructed for, or `null` if it was + /// not a subscription. + /// + /// The original subscription can be accessed using this index: + /// + /// ```dart + /// SubscriptionOfferDetailWrapper subscription = productDetail + /// .subscriptionOfferDetails[subscriptionIndex]; + /// ``` final int? subscriptionIndex; /// The offerToken of the subscription this [GooglePlayProductDetails] diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart index c82035d6007e..f2596d61326d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart @@ -22,30 +22,36 @@ class GooglePlayPurchaseDetails extends PurchaseDetails { pendingCompletePurchase = !billingClientPurchase.isAcknowledged; } - /// Generate a [PurchaseDetails] object based on an Android [Purchase] object. - factory GooglePlayPurchaseDetails.fromPurchase(PurchaseWrapper purchase) { - final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails( - purchaseID: purchase.orderId, - productID: purchase.products.isNotEmpty ? purchase.products.first : '', - verificationData: PurchaseVerificationData( - localVerificationData: purchase.originalJson, - serverVerificationData: purchase.purchaseToken, - source: kIAPSource), - transactionDate: purchase.purchaseTime.toString(), - billingClientPurchase: purchase, - status: const PurchaseStateConverter() - .toPurchaseStatus(purchase.purchaseState), - ); - - if (purchaseDetails.status == PurchaseStatus.error) { - purchaseDetails.error = IAPError( - source: kIAPSource, - code: kPurchaseErrorCode, - message: '', + /// Generates a [List] of [PurchaseDetails] based on an Android [Purchase] object. + /// + /// The list contains one entry per product. + static List fromPurchase( + PurchaseWrapper purchase) { + return purchase.products.map((String productId) { + final GooglePlayPurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails( + purchaseID: purchase.orderId, + productID: productId, + verificationData: PurchaseVerificationData( + localVerificationData: purchase.originalJson, + serverVerificationData: purchase.purchaseToken, + source: kIAPSource), + transactionDate: purchase.purchaseTime.toString(), + billingClientPurchase: purchase, + status: const PurchaseStateConverter() + .toPurchaseStatus(purchase.purchaseState), ); - } - return purchaseDetails; + if (purchaseDetails.status == PurchaseStatus.error) { + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: '', + ); + } + + return purchaseDetails; + }).toList(); } /// Points back to the [PurchaseWrapper] which was used to generate this diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart index 8da70aaab5f4..8da1abb8d66e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -22,6 +22,20 @@ const PurchaseWrapper dummyPurchase = PurchaseWrapper( obfuscatedProfileId: 'Profile103', ); +const PurchaseWrapper dummyMultipleProductsPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + products: ['product', 'product2'], + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + const PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( orderId: 'orderId', packageName: 'packageName', @@ -71,27 +85,42 @@ void main() { }); test('fromPurchase() should return correct PurchaseDetail object', () { - final GooglePlayPurchaseDetails details = - GooglePlayPurchaseDetails.fromPurchase(dummyPurchase); + final List details = + GooglePlayPurchaseDetails.fromPurchase(dummyMultipleProductsPurchase); - expect(details.purchaseID, dummyPurchase.orderId); - expect(details.productID, dummyPurchase.products.first); - expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); - expect(details.verificationData, isNotNull); - expect(details.verificationData.source, kIAPSource); - expect(details.verificationData.localVerificationData, - dummyPurchase.originalJson); - expect(details.verificationData.serverVerificationData, - dummyPurchase.purchaseToken); - expect(details.billingClientPurchase, dummyPurchase); - expect(details.pendingCompletePurchase, false); + expect(details[0].purchaseID, dummyMultipleProductsPurchase.orderId); + expect(details[0].productID, dummyMultipleProductsPurchase.products[0]); + expect(details[0].transactionDate, + dummyMultipleProductsPurchase.purchaseTime.toString()); + expect(details[0].verificationData, isNotNull); + expect(details[0].verificationData.source, kIAPSource); + expect(details[0].verificationData.localVerificationData, + dummyMultipleProductsPurchase.originalJson); + expect(details[0].verificationData.serverVerificationData, + dummyMultipleProductsPurchase.purchaseToken); + expect(details[0].billingClientPurchase, dummyMultipleProductsPurchase); + expect(details[0].pendingCompletePurchase, false); + + expect(details[1].purchaseID, dummyMultipleProductsPurchase.orderId); + expect(details[1].productID, dummyMultipleProductsPurchase.products[1]); + expect(details[1].transactionDate, + dummyMultipleProductsPurchase.purchaseTime.toString()); + expect(details[1].verificationData, isNotNull); + expect(details[1].verificationData.source, kIAPSource); + expect(details[1].verificationData.localVerificationData, + dummyMultipleProductsPurchase.originalJson); + expect(details[1].verificationData.serverVerificationData, + dummyMultipleProductsPurchase.purchaseToken); + expect(details[1].billingClientPurchase, dummyMultipleProductsPurchase); + expect(details[1].pendingCompletePurchase, false); }); test( 'fromPurchase() should return set pendingCompletePurchase to true for unacknowledged purchase', () { final GooglePlayPurchaseDetails details = - GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase) + .first; expect(details.purchaseID, dummyPurchase.orderId); expect(details.productID, dummyPurchase.products.first); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart index 578a0d01cc55..b56cb9b5f40a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -56,7 +56,7 @@ void main() { ); final BillingResultWrapper billingResultWrapper = await iapAndroidPlatformAddition.consumePurchase( - GooglePlayPurchaseDetails.fromPurchase(dummyPurchase)); + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase).first); expect(billingResultWrapper, equals(expectedBillingResult)); }); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index 6349ca2a3610..dfe7ee60dff6 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -90,7 +90,8 @@ void main() { name: acknowledgePurchaseCall, value: okValue), ); final PurchaseDetails purchase = - GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase) + .first; final BillingResultWrapper result = await iapAndroidPlatform.completePurchase(purchase); expect( @@ -720,7 +721,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'product': productDetails.productId, + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -811,7 +812,8 @@ void main() { applicationUserName: accountId, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: GooglePlayPurchaseDetails.fromPurchase( - dummyUnacknowledgedPurchase), + dummyUnacknowledgedPurchase) + .first, prorationMode: ProrationMode.deferred, )); await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); @@ -834,7 +836,8 @@ void main() { value: buildBillingResultMap(expectedBillingResult), ); final PurchaseDetails purchaseDetails = - GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase) + .first; final Completer completer = Completer(); purchaseDetails.status = PurchaseStatus.purchased; From 54b82d4a613e0226f9298b268d7c803e9bf8858e Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Mon, 1 May 2023 17:49:31 +0200 Subject: [PATCH 14/17] Add missing type declaration --- .../test/in_app_purchase_android_platform_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index dfe7ee60dff6..e46568645f77 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -721,7 +721,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'products': [productDetails.productId], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, From f8240c0f2cb714c282bc09967a6357f5e8e14d0e Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Mon, 1 May 2023 18:04:42 +0200 Subject: [PATCH 15/17] Replace `String.format` calls with concatenation --- .../inapppurchase/MethodCallHandlerImpl.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 38c6033594cb..c2ce590eecd2 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -224,9 +224,10 @@ private void launchBillingFlow( if (productDetails == null) { result.error( "NOT_FOUND", - String.format( - "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", - product, LOAD_PRODUCT_DOC_URL), + "Details for product " + + product + + " are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: " + + LOAD_PRODUCT_DOC_URL, null); return; } @@ -245,9 +246,12 @@ private void launchBillingFlow( if (!isValidOfferToken) { result.error( "INVALID_OFFER_TOKEN", - String.format( - "Offer token %s for product %s is not valid. Make sure to only pass offer tokens that belong to the product. To obtain offer tokens for a product, fetch the products. An example of how to fetch the products could be found here: %s", - offerToken, product, LOAD_PRODUCT_DOC_URL), + "Offer token " + + offerToken + + " for product " + + product + + " is not valid. Make sure to only pass offer tokens that belong to the product. To obtain offer tokens for a product, fetch the products. An example of how to fetch the products could be found here: " + + LOAD_PRODUCT_DOC_URL, null); return; } @@ -263,9 +267,10 @@ private void launchBillingFlow( } else if (oldProduct != null && !cachedProducts.containsKey(oldProduct)) { result.error( "IN_APP_PURCHASE_INVALID_OLD_PRODUCT", - String.format( - "Details for product %s are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: %s", - oldProduct, LOAD_PRODUCT_DOC_URL), + "Details for product " + + oldProduct + + " are not available. It might because products were not fetched prior to the call. Please fetch the products first. An example of how to fetch the products could be found here: " + + LOAD_PRODUCT_DOC_URL, null); return; } @@ -273,9 +278,9 @@ private void launchBillingFlow( if (activity == null) { result.error( "ACTIVITY_UNAVAILABLE", - String.format( - "Details for product %s are not available. This method must be run with the app in foreground.", - product), + "Details for product " + + product + + " are not available. This method must be run with the app in foreground.", null); return; } From f4cdfa49ae4a08cedb6dadd1b0c54133c5769598 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Thu, 4 May 2023 10:12:44 +0200 Subject: [PATCH 16/17] Swap back `inapp` and `subs` in `queryProductDetails()` --- .../lib/src/in_app_purchase_android_platform.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart index 34e844c947b6..ead862098ebe 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -81,7 +81,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { (BillingClient client) => client.queryProductDetails( productList: identifiers .map((String productId) => ProductWrapper( - productId: productId, productType: ProductType.subs)) + productId: productId, productType: ProductType.inapp)) .toList(), ), ), @@ -89,7 +89,7 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { (BillingClient client) => client.queryProductDetails( productList: identifiers .map((String productId) => ProductWrapper( - productId: productId, productType: ProductType.inapp)) + productId: productId, productType: ProductType.subs)) .toList(), ), ), From d2d84719fc565da9ddb2ac79ca76d23251be5904 Mon Sep 17 00:00:00 2001 From: Jeroen Weener Date: Mon, 15 May 2023 14:44:53 +0200 Subject: [PATCH 17/17] Align method string naming in `InAppPurchasePlugin` --- .../plugins/inapppurchase/InAppPurchasePlugin.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index 9486d109472e..c02bc8e893b3 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -37,17 +37,16 @@ static final class MethodNames { static final String LAUNCH_BILLING_FLOW = "BillingClient#launchBillingFlow(Activity, BillingFlowParams)"; static final String ON_PURCHASES_UPDATED = - "PurchasesUpdatedListener#onPurchasesUpdated(int, List)"; - static final String QUERY_PURCHASES_ASYNC = "BillingClient#queryPurchasesAsync(String)"; + "PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List)"; + static final String QUERY_PURCHASES_ASYNC = + "BillingClient#queryPurchasesAsync(QueryPurchaseParams, PurchaseResponseListener)"; static final String QUERY_PURCHASE_HISTORY_ASYNC = - "BillingClient#queryPurchaseHistoryAsync(String)"; + "BillingClient#queryPurchaseHistoryAsync(QueryPurchaseHistoryParams, PurchaseHistoryResponseListener)"; static final String CONSUME_PURCHASE_ASYNC = - "BillingClient#consumeAsync(String, ConsumeResponseListener)"; + "BillingClient#consumeAsync(ConsumeParams, ConsumeResponseListener)"; static final String ACKNOWLEDGE_PURCHASE = - "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; + "BillingClient#acknowledgePurchase(AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; static final String IS_FEATURE_SUPPORTED = "BillingClient#isFeatureSupported(String)"; - static final String LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW = - "BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)"; static final String GET_CONNECTION_STATE = "BillingClient#getConnectionState()"; private MethodNames() {};