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..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,3 +1,7 @@ +## 0.3.0 +* **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 * Updates gradle, AGP and fixes some lint errors. 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..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 @@ -32,23 +32,21 @@ 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)"; + "PurchasesUpdatedListener#onPurchasesUpdated(BillingResult, List)"; + static final String QUERY_PURCHASES_ASYNC = + "BillingClient#queryPurchasesAsync(QueryPurchaseParams, PurchaseResponseListener)"; static final String QUERY_PURCHASE_HISTORY_ASYNC = - "BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)"; + "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() {}; 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..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 @@ -4,9 +4,11 @@ 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 static io.flutter.plugins.inapppurchase.Translator.toProductList; import android.app.Activity; import android.app.Application; @@ -24,14 +26,19 @@ 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; @@ -41,7 +48,7 @@ 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 +58,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 +122,28 @@ 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 productList = toProductList(call.argument("productList")); + queryProductDetailsAsync(productList, 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 +154,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,77 +182,95 @@ 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 productList, final MethodChannel.Result result) { if (billingClientError(result)) { return; } - com.android.billingclient.api.SkuDetailsParams params = - com.android.billingclient.api.SkuDetailsParams.newBuilder() - .setType(skuType) - .setSkusList(skusList) - .build(); - billingClient.querySkuDetailsAsync( + QueryProductDetailsParams params = + QueryProductDetailsParams.newBuilder().setProductList(productList).build(); + billingClient.queryProductDetailsAsync( params, - new com.android.billingclient.api.SkuDetailsResponseListener() { + new ProductDetailsResponseListener() { @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); + 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, + String product, + @Nullable String offerToken, @Nullable String accountId, @Nullable String obfuscatedProfileId, - @Nullable String oldSku, + @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, + "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; } - if (oldSku == null + @Nullable + List subscriptionOfferDetails = + productDetails.getSubscriptionOfferDetails(); + if (subscriptionOfferDetails != null) { + boolean isValidOfferToken = false; + for (ProductDetails.SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) { + if (offerToken != null && offerToken.equals(offerDetails.getOfferToken())) { + isValidOfferToken = true; + break; + } + } + if (!isValidOfferToken) { + result.error( + "INVALID_OFFER_TOKEN", + "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; + } + } + + 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.", + "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, + "IN_APP_PURCHASE_INVALID_OLD_PRODUCT", + "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; } @@ -261,17 +278,25 @@ private void launchBillingFlow( if (activity == null) { result.error( "ACTIVITY_UNAVAILABLE", - "Details for sku " - + sku + "Details for product " + + product + " are not available. This method must be run with the app in foreground.", 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); } @@ -280,7 +305,7 @@ private void launchBillingFlow( } BillingFlowParams.SubscriptionUpdateParams.Builder subscriptionUpdateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder(); - if (oldSku != null && !oldSku.isEmpty() && purchaseToken != null) { + 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 +313,7 @@ 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 +325,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 +336,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 +344,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 +355,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 +409,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 +432,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) { + protected 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..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 @@ -4,69 +4,173 @@ 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 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 { - // 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 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) { 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("offerIdToken", 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 +188,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..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 @@ -10,17 +10,16 @@ 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_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_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.QUERY_SKU_DETAILS; 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,27 @@ 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.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,10 +80,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; private BillingClientFactory factory; @@ -213,59 +212,56 @@ public void endConnection() { } @Test - public void querySkuDetailsAsync() { - // Connect a billing client and set up the SKU query listeners + public void queryProductDetailsAsync() { + // Connect a billing client and set up the product query listeners establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); - 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("productList", buildProductMap(productsList, productType)); + MethodCall queryCall = new MethodCall(QUERY_PRODUCT_DETAILS, arguments); - // Query for SKU details + // Query for product 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); + 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 skuDetailsResponse = - asList(buildSkuDetails("foo")); + List productDetailsResponse = asList(buildProductDetails("foo")); BillingResult billingResult = BillingResult.newBuilder() .setResponseCode(100) .setDebugMessage("dummy debug message") .build(); - listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); + 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("skuDetailsList"), fromSkuDetailsList(skuDetailsResponse)); + 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("productList", buildProductMap(productsList, productType)); + 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. @@ -278,11 +274,11 @@ 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, arguments); @@ -308,15 +304,15 @@ 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); + arguments.put("oldProduct", null); MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow @@ -342,12 +338,12 @@ 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, arguments); methodChannelHandler.onMethodCall(launchCall, result); @@ -358,16 +354,16 @@ 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); + arguments.put("oldProduct", oldProductId); MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow @@ -392,12 +388,12 @@ 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, arguments); @@ -423,17 +419,17 @@ 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, arguments); @@ -459,18 +455,18 @@ 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, arguments); @@ -486,25 +482,25 @@ 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, arguments); @@ -534,10 +530,10 @@ 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, arguments); @@ -549,41 +545,42 @@ 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, 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); + 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()); } @@ -593,7 +590,7 @@ public void queryPurchases_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_PURCHASES_ASYNC, arguments), result); // Assert that we sent an error back. @@ -636,7 +633,7 @@ public Object answer(InvocationOnMock invocation) { any(QueryPurchasesParams.class), purchasesResponseListenerArgumentCaptor.capture()); HashMap arguments = new HashMap<>(); - arguments.put("skuType", BillingClient.SkuType.INAPP); + arguments.put("productType", BillingClient.ProductType.INAPP); methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES_ASYNC, arguments), result); lock.await(5000, TimeUnit.MILLISECONDS); @@ -667,7 +664,7 @@ 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); @@ -691,7 +688,7 @@ 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, arguments), result); @@ -830,102 +827,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); @@ -952,45 +853,65 @@ private void establishConnectedBillingClient( methodChannelHandler.onMethodCall(connectCall, result); } - private void queryForSkus(List skusList) { + private void queryForProducts(List productIdList) { // 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> productList = buildProductMap(productIdList, productType); + arguments.put("productList", productList); + 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 = + productIdList.stream().map(this::buildProductDetails).collect(toList()); BillingResult billingResult = BillingResult.newBuilder() .setResponseCode(100) .setDebugMessage("dummy debug message") .build(); - listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); + 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 com.android.billingclient.api.SkuDetails buildSkuDetails(String id) { + private ProductDetails buildProductDetails(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}", + "{\"title\":\"Example title\",\"description\":\"Example description\",\"productId\":\"%s\",\"type\":\"inapp\",\"name\":\"Example name\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":990000,\"priceCurrencyCode\":\"USD\",\"formattedPrice\":\"$0.99\"}}", 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()); + 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 details; + return null; } private Purchase buildPurchase(String orderId) { 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..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 @@ -14,8 +14,11 @@ 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,42 +29,56 @@ 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 = - "{\"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 fromSkuDetail() throws JSONException { - final com.android.billingclient.api.SkuDetails expected = - new com.android.billingclient.api.SkuDetails(SKU_DETAIL_EXAMPLE_JSON); + public void fromInAppProductDetail() + throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = + productDetailsConstructor.newInstance(IN_APP_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 fromSubsProductDetail() + throws InvocationTargetException, IllegalAccessException, InstantiationException { + final ProductDetails expected = + productDetailsConstructor.newInstance(SUBS_PRODUCT_DETAIL_EXAMPLE_JSON); + + Map serialized = Translator.fromProductDetail(expected); + + assertSerialized(expected, serialized); + } + + @Test + 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)); @@ -69,8 +86,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 @@ -141,7 +158,7 @@ public void fromPurchasesList_null() { } @Test - public void fromBillingResult() throws JSONException { + public void fromBillingResult() { BillingResult newBillingResult = BillingResult.newBuilder() .setDebugMessage("dummy debug message") @@ -154,7 +171,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); @@ -174,27 +191,88 @@ public void currencyCodeFromSymbol() { } } - private void assertSerialized( - com.android.billingclient.api.SkuDetails expected, Map serialized) { + private void assertSerialized(ProductDetails expected, Map serialized) { assertEquals(expected.getDescription(), serialized.get("description")); - assertEquals(expected.getFreeTrialPeriod(), serialized.get("freeTrialPeriod")); - assertEquals(expected.getIntroductoryPrice(), serialized.get("introductoryPrice")); + assertEquals(expected.getTitle(), serialized.get("title")); + assertEquals(expected.getName(), serialized.get("name")); + assertEquals(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( - expected.getIntroductoryPriceAmountMicros(), - serialized.get("introductoryPriceAmountMicros")); - assertEquals(expected.getIntroductoryPriceCycles(), serialized.get("introductoryPriceCycles")); - assertEquals(expected.getIntroductoryPricePeriod(), serialized.get("introductoryPricePeriod")); - assertEquals(expected.getPrice(), serialized.get("price")); + 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("$", 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.getOriginalPriceAmountMicros(), serialized.get("originalPriceAmountMicros")); + 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) { @@ -204,7 +282,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")); @@ -223,7 +301,7 @@ private void assertSerialized(PurchaseHistoryRecord expected, Map { productDetails.description, ), trailing: previousPurchase != null - ? IconButton( - onPressed: () { - final InAppPurchaseAndroidPlatformAddition addition = - InAppPurchasePlatformAddition.instance! - as InAppPurchaseAndroidPlatformAddition; - final SkuDetailsWrapper skuDetails = - (productDetails as GooglePlayProductDetails) - .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], @@ -503,6 +489,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..31133424afb6 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,9 @@ 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/product_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..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 @@ -133,31 +133,34 @@ 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 + 'productList': + productList.map((ProductWrapper product) => product.toJson()).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 +182,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 +225,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 +235,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 +326,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 +438,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 +458,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 +554,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 +565,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..62887b00d43c --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart @@ -0,0 +1,66 @@ +// 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'; + +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 serious 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..2a7c279b3fca --- /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 && + listEquals(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/product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart new file mode 100644 index 000000000000..48cd9ee738ec --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_wrapper.dart @@ -0,0 +1,50 @@ +// 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_wrapper.g.dart'; + +/// Dart wrapper around [`com.android.billingclient.api.Product`](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). +@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. + @JsonKey(defaultValue: '') + final String productId; + + /// The product 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/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..aa5688eb60f3 --- /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.offerIdToken, + 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 offerIdToken; + + /// 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.offerIdToken == offerIdToken && + listEquals(other.pricingPhases, pricingPhases); + } + + @override + int get hashCode { + return Object.hash( + basePlanId.hashCode, + offerId.hashCode, + offerTags.hashCode, + offerIdToken.hashCode, + pricingPhases.hashCode, + ); + } +} + +/// Represents a pricing phase, describing how a user pays at a point in time. +@JsonSerializable() +@RecurrenceModeConverter() +@immutable +class PricingPhaseWrapper { + /// Creates a new [PricingPhaseWrapper] from the supplied info. + @visibleForTesting + const PricingPhaseWrapper({ + required this.billingCycleCount, + required this.billingPeriod, + required this.formattedPrice, + required this.priceAmountMicros, + required this.priceCurrencyCode, + required this.recurrenceMode, + }); + + /// 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) + 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 PricingPhaseWrapper && + 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..eca645340fe5 --- /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,37 @@ +// 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() ?? + [], + offerIdToken: json['offerIdToken'] as String? ?? '', + pricingPhases: (json['pricingPhases'] as List?) + ?.map((e) => PricingPhaseWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); + +PricingPhaseWrapper _$PricingPhaseWrapperFromJson(Map json) => + PricingPhaseWrapper( + 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..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 @@ -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) => ProductWrapper( + productId: productId, productType: ProductType.inapp)) + .toList(), + ), + ), + billingClientManager.runWithClient( + (BillingClient client) => client.queryProductDetails( + productList: identifiers + .map((String productId) => ProductWrapper( + productId: productId, productType: ProductType.subs)) + .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,16 +140,22 @@ class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { changeSubscriptionParam = purchaseParam.changeSubscriptionParam; } + String? offerToken; + if (purchaseParam.productDetails is GooglePlayProductDetails) { + offerToken = + (purchaseParam.productDetails as GooglePlayProductDetails).offerToken; + } + 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, - ), + product: purchaseParam.productDetails.id, + offerToken: offerToken, + accountId: purchaseParam.applicationUserName, + oldProduct: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode), ); return billingResultWrapper.responseCode == BillingResponse.ok; } @@ -188,10 +203,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), ), ]); @@ -205,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( @@ -265,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 67e21dfad8f8..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 @@ -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) { @@ -119,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) { @@ -151,19 +151,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..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 @@ -11,35 +11,151 @@ 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, 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, + /// Generates a [GooglePlayProductDetails] object based on an Android + /// [ProductDetailsWrapper] object for an in-app product. + factory GooglePlayProductDetails._fromOneTimePurchaseProductDetails( + ProductDetailsWrapper productDetails, ) { - 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, + 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 = _extractCurrencySymbol(formattedPrice); + + return GooglePlayProductDetails._( + id: productDetails.productId, + title: productDetails.title, + description: productDetails.description, + price: formattedPrice, + rawPrice: rawPrice, + currencyCode: currencyCode, + currencySymbol: currencySymbol ?? currencyCode, + productDetails: productDetails, + ); + } + + /// 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. [subscriptionIndex] points to the index of + /// [productDetails.subscriptionOfferDetails] for which the + /// [GooglePlayProductDetails] is constructed. + 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 PricingPhaseWrapper firstPricingPhase = + subscriptionOfferDetails.pricingPhases.first; + final String formattedPrice = firstPricingPhase.formattedPrice; + final double rawPrice = firstPricingPhase.priceAmountMicros / 1000000.0; + final String currencyCode = firstPricingPhase.priceCurrencyCode; + final String? currencySymbol = _extractCurrencySymbol(formattedPrice); + + return GooglePlayProductDetails._( + id: productDetails.productId, + title: productDetails.title, + description: productDetails.description, + price: formattedPrice, + rawPrice: rawPrice, + currencyCode: currencyCode, + currencySymbol: currencySymbol ?? currencyCode, + productDetails: productDetails, + subscriptionIndex: subscriptionIndex, ); } - /// Points back to the [SkuDetailsWrapper] object that was used to generate - /// this [GooglePlayProductDetails] object. - final SkuDetailsWrapper skuDetails; + /// Generates a list of [GooglePlayProductDetails] based on an Android + /// [ProductDetailsWrapper] object. + /// + /// 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, + ) { + 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; + } + } + + /// 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 [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] + /// object was contructed for, or `null` if it was not a subscription. + String? get offerToken => subscriptionIndex != null && + productDetails.subscriptionOfferDetails != null + ? productDetails + .subscriptionOfferDetails![subscriptionIndex!].offerIdToken + : 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..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.sku, - 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/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index f1aa0e10a7ee..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" @@ -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..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 @@ -8,8 +8,8 @@ import 'package:in_app_purchase_android/billing_client_wrappers.dart'; import 'package:in_app_purchase_android/src/channel.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 +115,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 +127,22 @@ 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 ProductWrapper( + productId: 'invalid', productType: 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,41 @@ 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 ProductWrapper( + productId: 'invalid', productType: 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 ProductWrapper( + productId: 'invalid', productType: 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 +201,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.launchBillingFlow( - sku: skuDetails.sku, + 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 +230,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.launchBillingFlow( - sku: skuDetails.sku, + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, - oldSku: dummyOldPurchase.sku), + oldProduct: dummyOldPurchase.products.first), throwsAssertionError); expect( billingClient.launchBillingFlow( - sku: skuDetails.sku, + product: productDetails.productId, accountId: accountId, obfuscatedProfileId: profileId, purchaseToken: dummyOldPurchase.purchaseToken), @@ -250,24 +262,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.launchBillingFlow( - sku: skuDetails.sku, + 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,7 +296,7 @@ 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 = @@ -292,19 +304,19 @@ void main() { expect( await billingClient.launchBillingFlow( - sku: skuDetails.sku, + 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,7 +335,7 @@ 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 = @@ -331,19 +343,19 @@ void main() { expect( await billingClient.launchBillingFlow( - sku: skuDetails.sku, + 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 +372,16 @@ void main() { name: launchMethodName, value: buildBillingResultMap(expectedBillingResult), ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; - expect(await billingClient.launchBillingFlow(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 +389,10 @@ void main() { stubPlatform.addResponse( name: launchMethodName, ); - const SkuDetailsWrapper skuDetails = dummySkuDetails; + const ProductDetailsWrapper productDetails = dummyOneTimeProductDetails; expect( - await billingClient.launchBillingFlow(sku: skuDetails.sku), + await billingClient.launchBillingFlow( + product: productDetails.productId), equals(const BillingResultWrapper( responseCode: BillingResponse.error, debugMessage: kInvalidBillingResultErrorMessage))); @@ -406,7 +421,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -426,7 +441,7 @@ void main() { }); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); @@ -438,7 +453,7 @@ void main() { name: queryPurchasesMethodName, ); final PurchasesResultWrapper response = - await billingClient.queryPurchases(SkuType.inapp); + await billingClient.queryPurchases(ProductType.inapp); expect( response.billingResult, @@ -452,7 +467,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 +489,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, equals(expectedList)); }); @@ -492,7 +507,7 @@ void main() { }); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect(response.billingResult, equals(expectedBillingResult)); expect(response.purchaseHistoryRecordList, isEmpty); @@ -503,7 +518,7 @@ void main() { name: queryPurchaseHistoryMethodName, ); final PurchasesHistoryResult response = - await billingClient.queryPurchaseHistory(SkuType.inapp); + await billingClient.queryPurchaseHistory(ProductType.inapp); expect( response.billingResult, @@ -610,47 +625,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..3bd6a497490f --- /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', + offerIdToken: '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'], + offerIdToken: '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'], + offerIdToken: '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, + 'offerIdToken': original.offerIdToken, + '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/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), + ); +} 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..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 @@ -11,7 +11,7 @@ const PurchaseWrapper dummyPurchase = PurchaseWrapper( packageName: 'packageName', purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', isAutoRenewing: false, originalJson: '', @@ -22,12 +22,26 @@ 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', purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', isAutoRenewing: false, originalJson: '', @@ -40,7 +54,7 @@ const PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = PurchaseHistoryRecordWrapper( purchaseTime: 0, signature: 'signature', - skus: ['sku'], + products: ['product'], purchaseToken: 'purchaseToken', originalJson: '', developerPayload: 'dummy payload', @@ -51,7 +65,7 @@ const PurchaseWrapper dummyOldPurchase = PurchaseWrapper( packageName: 'oldPackageName', purchaseTime: 0, signature: 'oldSignature', - skus: ['oldSku'], + products: ['oldProduct'], purchaseToken: 'oldPurchaseToken', isAutoRenewing: false, originalJson: '', @@ -71,30 +85,45 @@ 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.sku); - 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.sku); + expect(details.productID, dummyPurchase.products.first); expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); expect(details.verificationData, isNotNull); expect(details.verificationData.source, kIAPSource); @@ -205,7 +234,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 +252,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 2d1436885427..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 GooglePlayProductDetails product = - GooglePlayProductDetails.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..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)); }); @@ -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 a679def27d51..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 @@ -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() { @@ -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( @@ -114,18 +115,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 +141,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 +167,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 +187,8 @@ void main() { value: { 'responseCode': const BillingResponseConverter().toJson(responseCode), - 'skuDetailsList': >[ - buildSkuMap(dummySkuDetails) + 'productDetailsList': >[ + buildProductMap(dummyOneTimeProductDetails) ] }, additionalStepBeforeReturn: (dynamic _) { @@ -189,7 +198,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 +275,7 @@ void main() { ); }); - test('returns SkuDetailsResponseWrapper', () async { + test('returns ProductDetailsResponseWrapper', () async { final Completer> completer = Completer>(); final Stream> stream = @@ -294,7 +303,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 +313,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 +334,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 +353,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -370,7 +379,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyNonConsumable( purchaseParam: purchaseParam); @@ -379,11 +389,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 +424,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); final PurchaseDetails result = await completer.future; @@ -427,7 +438,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 +457,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -487,7 +498,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); final bool launchResult = await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -515,8 +527,9 @@ void main() { final bool result = await iapAndroidPlatform.buyNonConsumable( purchaseParam: GooglePlayPurchaseParam( - productDetails: - GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + productDetails: GooglePlayProductDetails.fromProductDetails( + dummyOneTimeProductDetails) + .first)); // Verify that the failure has been converted and returned expect(result, isFalse); @@ -535,15 +548,16 @@ void main() { final bool result = await iapAndroidPlatform.buyConsumable( purchaseParam: GooglePlayPurchaseParam( - productDetails: - GooglePlayProductDetails.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 +575,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -602,7 +616,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -620,7 +635,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 +654,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'skus': [skuDetails.sku], + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -677,7 +692,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable( purchaseParam: purchaseParam, autoConsume: false); @@ -687,7 +703,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 +721,7 @@ void main() { 'purchasesList': [ { 'orderId': 'orderID1', - 'sku': skuDetails.sku, + 'products': [productDetails.productId], 'isAutoRenewing': false, 'packageName': 'package', 'purchaseTime': 1231231231, @@ -746,7 +762,8 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId); await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); @@ -759,7 +776,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,11 +807,13 @@ void main() { subscription.cancel(); }, onDone: () {}); final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( - productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + productDetails: + GooglePlayProductDetails.fromProductDetails(productDetails).first, applicationUserName: accountId, changeSubscriptionParam: ChangeSubscriptionParam( oldPurchaseDetails: GooglePlayPurchaseDetails.fromPurchase( - dummyUnacknowledgedPurchase), + dummyUnacknowledgedPurchase) + .first, prorationMode: ProrationMode.deferred, )); await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); @@ -817,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;