diff --git a/packages/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/CHANGELOG.md index ffdb367146ae..1287dea28122 100644 --- a/packages/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/CHANGELOG.md @@ -1,3 +1,24 @@ +## 0.3.0 + +* Migrate the `Google Play Library` to 2.0.3. + * Introduce a new class `BillingResultWrapper` which contains a detailed result of a BillingClient operation. + * **[Breaking Change]:** All the BillingClient methods that previously return a `BillingResponse` now return a `BillingResultWrapper`, including: `launchBillingFlow`, `startConnection` and `consumeAsync`. + * **[Breaking Change]:** The `SkuDetailsResponseWrapper` now contains a `billingResult` field in place of `billingResponse` field. + * A `billingResult` field is added to the `PurchasesResultWrapper`. + * Other Updates to the "billing_client_wrappers": + * Updates to the `PurchaseWrapper`: Add `developerPayload`, `purchaseState` and `isAcknowledged` fields. + * Updates to the `SkuDetailsWrapper`: Add `originalPrice` and `originalPriceAmountMicros` fields. + * **[Breaking Change]:** The `BillingClient.queryPurchaseHistory` is updated to return a `PurchasesHistoryResult`, which contains a list of `PurchaseHistoryRecordWrapper` instead of `PurchaseWrapper`. A `PurchaseHistoryRecordWrapper` object has the same fields and values as A `PurchaseWrapper` object, except that a `PurchaseHistoryRecordWrapper` object does not contain `isAutoRenewing`, `orderId` and `packageName`. + * Add a new `BillingClient.acknowledgePurchase` API. Starting from this version, the developer has to acknowledge any purchase on Android using this API within 3 days of purchase, or the user will be refunded. Note that if a product is "consumed" via `BillingClient.consumeAsync`, it is implicitly acknowledged. + * **[Breaking Change]:** Added `enablePendingPurchases` in `BillingClientWrapper`. The application has to call this method before calling `BillingClientWrapper.startConnection`. See [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases) for more information. + * Updates to the "InAppPurchaseConnection": + * **[Breaking Change]:** `InAppPurchaseConnection.completePurchase` now returns a `Future` instead of `Future`. A new optional parameter `{String developerPayload}` has also been added to the API. On Android, this API does not throw an exception anymore, it instead acknowledge the purchase. If a purchase is not completed within 3 days on Android, the user will be refunded. + * **[Breaking Change]:** `InAppPurchaseConnection.consumePurchase` now returns a `Future` instead of `Future`. A new optional parameter `{String developerPayload}` has also been added to the API. + * A new boolean field `pendingCompletePurchase` has been added to the `PurchaseDetails` class. Which can be used as an indicator of whether to call `InAppPurchaseConnection.completePurchase` on the purchase. + * **[Breaking Change]:** Added `enablePendingPurchases` in `InAppPurchaseConnection`. The application has to call this method when initializing the `InAppPurchaseConnection` on Android. See [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases) for more information. + * Misc: Some documentation updates reflecting the `BillingClient` migration and some documentation fixes. + * Refer to [Google Play Billing Library Release Note](https://developer.android.com/google/play/billing/billing_library_releases_notes#release-2_0) for a detailed information on the update. + ## 0.2.2+6 * Correct a comment. diff --git a/packages/in_app_purchase/README.md b/packages/in_app_purchase/README.md index ce564d14fea3..c366e149bd8d 100644 --- a/packages/in_app_purchase/README.md +++ b/packages/in_app_purchase/README.md @@ -114,15 +114,15 @@ for (PurchaseDetails purchase in response.pastPurchases) { } ``` -Note that the App Store does not have any APIs for querying consummable -products, and Google Play considers consummable products to no longer be owned +Note that the App Store does not have any APIs for querying consumable +products, and Google Play considers consumable products to no longer be owned once they're marked as consumed and fails to return them here. For restoring these across devices you'll need to persist them on your own server and query that as well. ### Making a purchase -Both storefronts handle consummable and non-consummable products differently. If +Both storefronts handle consumable and non-consumable products differently. If you're using `InAppPurchaseConnection`, you need to make a distinction here and call the right purchase method for each type. diff --git a/packages/in_app_purchase/android/build.gradle b/packages/in_app_purchase/android/build.gradle index 54bd5e183713..5d577c259d1b 100644 --- a/packages/in_app_purchase/android/build.gradle +++ b/packages/in_app_purchase/android/build.gradle @@ -35,7 +35,7 @@ android { dependencies { implementation 'androidx.annotation:annotation:1.0.0' - implementation 'com.android.billingclient:billing:1.2' + implementation 'com.android.billingclient:billing:2.0.3' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.17.0' androidTestImplementation 'androidx.test:runner:1.1.1' diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java index b9ec9f6395a3..b320c17aa992 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java @@ -17,7 +17,10 @@ interface BillingClientFactory { * * @param context The context used to create the {@link BillingClient}. * @param channel The method channel used to create the {@link BillingClient}. + * @param enablePendingPurchases Whether to enable pending purchases. Throws an exception if it is + * false. * @return The {@link BillingClient} object that is created. */ - BillingClient createBillingClient(@NonNull Context context, @NonNull MethodChannel channel); + BillingClient createBillingClient( + @NonNull Context context, @NonNull MethodChannel channel, boolean enablePendingPurchases); } diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java index 383fcabbb3c5..9bfddaf57545 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java @@ -12,9 +12,12 @@ final class BillingClientFactoryImpl implements BillingClientFactory { @Override - public BillingClient createBillingClient(Context context, MethodChannel channel) { - return BillingClient.newBuilder(context) - .setListener(new PluginPurchaseListener(channel)) - .build(); + public BillingClient createBillingClient( + Context context, MethodChannel channel, boolean enablePendingPurchases) { + BillingClient.Builder builder = BillingClient.newBuilder(context); + if (enablePendingPurchases) { + builder.enablePendingPurchases(); + } + return builder.setListener(new PluginPurchaseListener(channel)).build(); } } diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java index 33910eea9122..a9302d10df4e 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -36,6 +36,8 @@ static final class MethodNames { "BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)"; static final String CONSUME_PURCHASE_ASYNC = "BillingClient#consumeAsync(String, ConsumeResponseListener)"; + static final String ACKNOWLEDGE_PURCHASE = + "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; private MethodNames() {}; } diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index 2abcc4b5f634..9108ab36bcd1 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -4,7 +4,7 @@ package io.flutter.plugins.inapppurchase; -import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult; import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; @@ -13,11 +13,15 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; -import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.SkuDetails; import com.android.billingclient.api.SkuDetailsParams; @@ -69,7 +73,10 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { isReady(result); break; case InAppPurchasePlugin.MethodNames.START_CONNECTION: - startConnection((int) call.argument("handle"), result); + startConnection( + (int) call.argument("handle"), + (boolean) call.argument("enablePendingPurchases"), + result); break; case InAppPurchasePlugin.MethodNames.END_CONNECTION: endConnection(result); @@ -89,7 +96,16 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) { queryPurchaseHistoryAsync((String) call.argument("skuType"), result); break; case InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC: - consumeAsync((String) call.argument("purchaseToken"), result); + consumeAsync( + (String) call.argument("purchaseToken"), + (String) call.argument("developerPayload"), + result); + break; + case InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE: + acknowledgePurchase( + (String) call.argument("purchaseToken"), + (String) call.argument("developerPayload"), + result); break; default: result.notImplemented(); @@ -123,11 +139,12 @@ private void querySkuDetailsAsync( billingClient.querySkuDetailsAsync( params, new SkuDetailsResponseListener() { + @Override public void onSkuDetailsResponse( - int responseCode, @Nullable List skuDetailsList) { + BillingResult billingResult, List skuDetailsList) { updateCachedSkus(skuDetailsList); final Map skuDetailsResponse = new HashMap<>(); - skuDetailsResponse.put("responseCode", responseCode); + skuDetailsResponse.put("billingResult", Translator.fromBillingResult(billingResult)); skuDetailsResponse.put("skuDetailsList", fromSkuDetailsList(skuDetailsList)); result.success(skuDetailsResponse); } @@ -164,10 +181,13 @@ private void launchBillingFlow( if (accountId != null && !accountId.isEmpty()) { paramsBuilder.setAccountId(accountId); } - result.success(billingClient.launchBillingFlow(activity, paramsBuilder.build())); + result.success( + Translator.fromBillingResult( + billingClient.launchBillingFlow(activity, paramsBuilder.build()))); } - private void consumeAsync(String purchaseToken, final MethodChannel.Result result) { + private void consumeAsync( + String purchaseToken, String developerPayload, final MethodChannel.Result result) { if (billingClientError(result)) { return; } @@ -175,12 +195,19 @@ private void consumeAsync(String purchaseToken, final MethodChannel.Result resul ConsumeResponseListener listener = new ConsumeResponseListener() { @Override - public void onConsumeResponse( - @BillingClient.BillingResponse int responseCode, String outToken) { - result.success(responseCode); + public void onConsumeResponse(BillingResult billingResult, String outToken) { + result.success(Translator.fromBillingResult(billingResult)); } }; - billingClient.consumeAsync(purchaseToken, listener); + ConsumeParams.Builder paramsBuilder = + ConsumeParams.newBuilder().setPurchaseToken(purchaseToken); + + if (developerPayload != null) { + paramsBuilder.setDeveloperPayload(developerPayload); + } + ConsumeParams params = paramsBuilder.build(); + + billingClient.consumeAsync(params, listener); } private void queryPurchases(String skuType, MethodChannel.Result result) { @@ -201,18 +228,23 @@ private void queryPurchaseHistoryAsync(String skuType, final MethodChannel.Resul skuType, new PurchaseHistoryResponseListener() { @Override - public void onPurchaseHistoryResponse(int responseCode, List purchasesList) { + public void onPurchaseHistoryResponse( + BillingResult billingResult, List purchasesList) { final Map serialized = new HashMap<>(); - serialized.put("responseCode", responseCode); - serialized.put("purchasesList", fromPurchasesList(purchasesList)); + serialized.put("billingResult", Translator.fromBillingResult(billingResult)); + serialized.put( + "purchaseHistoryRecordList", fromPurchaseHistoryRecordList(purchasesList)); result.success(serialized); } }); } - private void startConnection(final int handle, final MethodChannel.Result result) { + private void startConnection( + final int handle, final boolean enablePendingPurchases, final MethodChannel.Result result) { if (billingClient == null) { - billingClient = billingClientFactory.createBillingClient(applicationContext, methodChannel); + billingClient = + billingClientFactory.createBillingClient( + applicationContext, methodChannel, enablePendingPurchases); } billingClient.startConnection( @@ -220,14 +252,14 @@ private void startConnection(final int handle, final MethodChannel.Result result private boolean alreadyFinished = false; @Override - public void onBillingSetupFinished(int responseCode) { + public void onBillingSetupFinished(BillingResult billingResult) { if (alreadyFinished) { Log.d(TAG, "Tried to call onBilllingSetupFinished multiple times."); return; } alreadyFinished = true; // Consider the fact that we've finished a success, leave it to the Dart side to validate the responseCode. - result.success(responseCode); + result.success(Translator.fromBillingResult(billingResult)); } @Override @@ -239,6 +271,26 @@ public void onBillingServiceDisconnected() { }); } + private void acknowledgePurchase( + String purchaseToken, @Nullable String developerPayload, final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + AcknowledgePurchaseParams params = + AcknowledgePurchaseParams.newBuilder() + .setDeveloperPayload(developerPayload) + .setPurchaseToken(purchaseToken) + .build(); + billingClient.acknowledgePurchase( + params, + new AcknowledgePurchaseResponseListener() { + @Override + public void onAcknowledgePurchaseResponse(BillingResult billingResult) { + result.success(Translator.fromBillingResult(billingResult)); + } + }); + } + private void updateCachedSkus(@Nullable List skuDetailsList) { if (skuDetailsList == null) { return; diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java index db3260cb5a0c..20ab8ad92e65 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.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.fromPurchasesList; import androidx.annotation.Nullable; +import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchasesUpdatedListener; import io.flutter.plugin.common.MethodChannel; @@ -22,9 +24,10 @@ class PluginPurchaseListener implements PurchasesUpdatedListener { } @Override - public void onPurchasesUpdated(int responseCode, @Nullable List purchases) { + public void onPurchasesUpdated(BillingResult billingResult, @Nullable List purchases) { final Map callbackArgs = new HashMap<>(); - callbackArgs.put("responseCode", responseCode); + callbackArgs.put("billingResult", fromBillingResult(billingResult)); + callbackArgs.put("responseCode", billingResult.getResponseCode()); callbackArgs.put("purchasesList", fromPurchasesList(purchases)); channel.invokeMethod(InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED, callbackArgs); } diff --git a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index 4502b7d43612..80b6f1362255 100644 --- a/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -5,8 +5,10 @@ package io.flutter.plugins.inapppurchase; import androidx.annotation.Nullable; +import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.SkuDetails; import java.util.ArrayList; import java.util.Collections; @@ -31,6 +33,8 @@ static HashMap fromSkuDetail(SkuDetails detail) { info.put("type", detail.getType()); info.put("isRewarded", detail.isRewarded()); info.put("subscriptionPeriod", detail.getSubscriptionPeriod()); + info.put("originalPrice", detail.getOriginalPrice()); + info.put("originalPriceAmountMicros", detail.getOriginalPriceAmountMicros()); return info; } @@ -57,6 +61,21 @@ static HashMap fromPurchase(Purchase purchase) { info.put("sku", purchase.getSku()); info.put("isAutoRenewing", purchase.isAutoRenewing()); info.put("originalJson", purchase.getOriginalJson()); + info.put("developerPayload", purchase.getDeveloperPayload()); + info.put("isAcknowledged", purchase.isAcknowledged()); + info.put("purchaseState", purchase.getPurchaseState()); + return info; + } + + static HashMap fromPurchaseHistoryRecord( + PurchaseHistoryRecord purchaseHistoryRecord) { + HashMap info = new HashMap<>(); + info.put("purchaseTime", purchaseHistoryRecord.getPurchaseTime()); + info.put("purchaseToken", purchaseHistoryRecord.getPurchaseToken()); + info.put("signature", purchaseHistoryRecord.getSignature()); + info.put("sku", purchaseHistoryRecord.getSku()); + info.put("developerPayload", purchaseHistoryRecord.getDeveloperPayload()); + info.put("originalJson", purchaseHistoryRecord.getOriginalJson()); return info; } @@ -72,10 +91,31 @@ static List> fromPurchasesList(@Nullable List return serialized; } + static List> fromPurchaseHistoryRecordList( + @Nullable List purchaseHistoryRecords) { + if (purchaseHistoryRecords == null) { + return Collections.emptyList(); + } + + List> serialized = new ArrayList<>(); + for (PurchaseHistoryRecord purchaseHistoryRecord : purchaseHistoryRecords) { + serialized.add(fromPurchaseHistoryRecord(purchaseHistoryRecord)); + } + return serialized; + } + static HashMap fromPurchasesResult(PurchasesResult purchasesResult) { HashMap info = new HashMap<>(); info.put("responseCode", purchasesResult.getResponseCode()); + info.put("billingResult", fromBillingResult(purchasesResult.getBillingResult())); info.put("purchasesList", fromPurchasesList(purchasesResult.getPurchasesList())); return info; } + + static HashMap fromBillingResult(BillingResult billingResult) { + HashMap info = new HashMap<>(); + info.put("responseCode", billingResult.getResponseCode()); + info.put("debugMessage", billingResult.getDebugMessage()); + return info; + } } diff --git a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index 47bfc113c081..be00ac4e6e91 100644 --- a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -1,5 +1,6 @@ package io.flutter.plugins.inapppurchase; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION; import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; @@ -10,6 +11,8 @@ 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.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult; import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; @@ -21,6 +24,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.refEq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -30,15 +34,20 @@ import android.app.Activity; import android.content.Context; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; import com.android.billingclient.api.BillingClient; -import com.android.billingclient.api.BillingClient.BillingResponse; import com.android.billingclient.api.BillingClient.SkuType; import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeResponseListener; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.SkuDetails; import com.android.billingclient.api.SkuDetailsParams; @@ -68,8 +77,10 @@ public class MethodCallHandlerTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); - - factory = (context, channel) -> mockBillingClient; + factory = + (@NonNull Context context, + @NonNull MethodChannel channel, + boolean enablePendingPurchases) -> mockBillingClient; methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory); } @@ -114,15 +125,21 @@ public void isReady_clientDisconnected() { public void startConnection() { ArgumentCaptor captor = mockStartConnection(); verify(result, never()).success(any()); - captor.getValue().onBillingSetupFinished(100); - - verify(result, times(1)).success(100); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + captor.getValue().onBillingSetupFinished(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); } @Test public void startConnection_multipleCalls() { - Map arguments = new HashMap<>(); + Map arguments = new HashMap<>(); arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); MethodCall call = new MethodCall(START_CONNECTION, arguments); ArgumentCaptor captor = ArgumentCaptor.forClass(BillingClientStateListener.class); @@ -130,11 +147,27 @@ public void startConnection_multipleCalls() { methodChannelHandler.onMethodCall(call, result); verify(result, never()).success(any()); - captor.getValue().onBillingSetupFinished(100); - captor.getValue().onBillingSetupFinished(200); - captor.getValue().onBillingSetupFinished(300); - - verify(result, times(1)).success(100); + BillingResult billingResult1 = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + BillingResult billingResult2 = + BillingResult.newBuilder() + .setResponseCode(200) + .setDebugMessage("dummy debug message") + .build(); + BillingResult billingResult3 = + BillingResult.newBuilder() + .setResponseCode(300) + .setDebugMessage("dummy debug message") + .build(); + + captor.getValue().onBillingSetupFinished(billingResult1); + captor.getValue().onBillingSetupFinished(billingResult2); + captor.getValue().onBillingSetupFinished(billingResult3); + + verify(result, times(1)).success(fromBillingResult(billingResult1)); verify(result, times(1)).success(any()); } @@ -142,8 +175,9 @@ public void startConnection_multipleCalls() { public void endConnection() { // Set up a connected BillingClient instance final int disconnectCallbackHandle = 22; - Map arguments = new HashMap<>(); + Map arguments = new HashMap<>(); arguments.put("handle", disconnectCallbackHandle); + arguments.put("enablePendingPurchases", true); MethodCall connectCall = new MethodCall(START_CONNECTION, arguments); ArgumentCaptor captor = ArgumentCaptor.forClass(BillingClientStateListener.class); @@ -190,11 +224,16 @@ public void querySkuDetailsAsync() { // Assert that we handed result BillingClient's response int responseCode = 200; List skuDetailsResponse = asList(buildSkuDetails("foo")); - listenerCaptor.getValue().onSkuDetailsResponse(responseCode, skuDetailsResponse); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); verify(result).success(resultCaptor.capture()); HashMap resultData = resultCaptor.getValue(); - assertEquals(resultData.get("responseCode"), responseCode); + assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); assertEquals(resultData.get("skuDetailsList"), fromSkuDetailsList(skuDetailsResponse)); } @@ -229,8 +268,12 @@ public void launchBillingFlow_ok_nullAccountId() { MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow - int responseCode = BillingResponse.OK; - when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(responseCode); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); methodChannelHandler.onMethodCall(launchCall, result); // Verify we pass the arguments to the billing flow @@ -243,7 +286,7 @@ public void launchBillingFlow_ok_nullAccountId() { // Verify we pass the response code to result verify(result, never()).error(any(), any(), any()); - verify(result, times(1)).success(responseCode); + verify(result, times(1)).success(fromBillingResult(billingResult)); } @Test @@ -277,8 +320,12 @@ public void launchBillingFlow_ok_AccountId() { MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); // Launch the billing flow - int responseCode = BillingResponse.OK; - when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(responseCode); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); methodChannelHandler.onMethodCall(launchCall, result); // Verify we pass the arguments to the billing flow @@ -291,7 +338,7 @@ public void launchBillingFlow_ok_AccountId() { // Verify we pass the response code to result verify(result, never()).error(any(), any(), any()); - verify(result, times(1)).success(responseCode); + verify(result, times(1)).success(fromBillingResult(billingResult)); } @Test @@ -335,9 +382,14 @@ public void launchBillingFlow_skuNotFound() { public void queryPurchases() { establishConnectedBillingClient(null, null); PurchasesResult purchasesResult = mock(PurchasesResult.class); - when(purchasesResult.getResponseCode()).thenReturn(BillingResponse.OK); Purchase purchase = buildPurchase("foo"); when(purchasesResult.getPurchasesList()).thenReturn(asList(purchase)); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(purchasesResult.getBillingResult()).thenReturn(billingResult); when(mockBillingClient.queryPurchases(SkuType.INAPP)).thenReturn(purchasesResult); HashMap arguments = new HashMap<>(); @@ -370,8 +422,12 @@ public void queryPurchaseHistoryAsync() { // Set up an established billing client and all our mocked responses establishConnectedBillingClient(null, null); ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); - int responseCode = BillingResponse.OK; - List purchasesList = asList(buildPurchase("foo")); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + List purchasesList = asList(buildPurchaseHistoryRecord("foo")); HashMap arguments = new HashMap<>(); arguments.put("skuType", SkuType.INAPP); ArgumentCaptor listenerCaptor = @@ -383,11 +439,12 @@ public void queryPurchaseHistoryAsync() { // Verify we pass the data to result verify(mockBillingClient) .queryPurchaseHistoryAsync(eq(SkuType.INAPP), listenerCaptor.capture()); - listenerCaptor.getValue().onPurchaseHistoryResponse(responseCode, purchasesList); + listenerCaptor.getValue().onPurchaseHistoryResponse(billingResult, purchasesList); verify(result).success(resultCaptor.capture()); HashMap resultData = resultCaptor.getValue(); - assertEquals(responseCode, resultData.get("responseCode")); - assertEquals(fromPurchasesList(purchasesList), resultData.get("purchasesList")); + assertEquals(fromBillingResult(billingResult), resultData.get("billingResult")); + assertEquals( + fromPurchaseHistoryRecordList(purchasesList), resultData.get("purchaseHistoryRecordList")); } @Test @@ -409,45 +466,95 @@ public void queryPurchaseHistoryAsync_clientDisconnected() { public void onPurchasesUpdatedListener() { PluginPurchaseListener listener = new PluginPurchaseListener(mockMethodChannel); - int responseCode = BillingResponse.OK; + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); List purchasesList = asList(buildPurchase("foo")); ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); doNothing() .when(mockMethodChannel) .invokeMethod(eq(ON_PURCHASES_UPDATED), resultCaptor.capture()); - listener.onPurchasesUpdated(responseCode, purchasesList); + listener.onPurchasesUpdated(billingResult, purchasesList); HashMap resultData = resultCaptor.getValue(); - assertEquals(responseCode, resultData.get("responseCode")); + assertEquals(fromBillingResult(billingResult), resultData.get("billingResult")); assertEquals(fromPurchasesList(purchasesList), resultData.get("purchasesList")); } @Test public void consumeAsync() { establishConnectedBillingClient(null, null); - ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResponse.class); - int responseCode = BillingResponse.OK; + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); HashMap arguments = new HashMap<>(); arguments.put("purchaseToken", "mockToken"); + arguments.put("developerPayload", "mockPayload"); ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ConsumeResponseListener.class); methodChannelHandler.onMethodCall(new MethodCall(CONSUME_PURCHASE_ASYNC, arguments), result); + ConsumeParams params = + ConsumeParams.newBuilder() + .setDeveloperPayload("mockPayload") + .setPurchaseToken("mockToken") + .build(); + + // Verify we pass the data to result + verify(mockBillingClient).consumeAsync(refEq(params), listenerCaptor.capture()); + + listenerCaptor.getValue().onConsumeResponse(billingResult, "mockToken"); + verify(result).success(resultCaptor.capture()); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void acknowledgePurchase() { + establishConnectedBillingClient(null, null); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + HashMap arguments = new HashMap<>(); + arguments.put("purchaseToken", "mockToken"); + arguments.put("developerPayload", "mockPayload"); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AcknowledgePurchaseResponseListener.class); + + methodChannelHandler.onMethodCall(new MethodCall(ACKNOWLEDGE_PURCHASE, arguments), result); + + AcknowledgePurchaseParams params = + AcknowledgePurchaseParams.newBuilder() + .setDeveloperPayload("mockPayload") + .setPurchaseToken("mockToken") + .build(); + // Verify we pass the data to result - verify(mockBillingClient).consumeAsync(eq("mockToken"), listenerCaptor.capture()); + verify(mockBillingClient).acknowledgePurchase(refEq(params), listenerCaptor.capture()); - listenerCaptor.getValue().onConsumeResponse(responseCode, "mockToken"); + listenerCaptor.getValue().onAcknowledgePurchaseResponse(billingResult); verify(result).success(resultCaptor.capture()); // Verify we pass the response code to result verify(result, never()).error(any(), any(), any()); - verify(result, times(1)).success(responseCode); + verify(result, times(1)).success(fromBillingResult(billingResult)); } private ArgumentCaptor mockStartConnection() { - Map arguments = new HashMap<>(); + Map arguments = new HashMap<>(); arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); MethodCall call = new MethodCall(START_CONNECTION, arguments); ArgumentCaptor captor = ArgumentCaptor.forClass(BillingClientStateListener.class); @@ -458,10 +565,11 @@ private ArgumentCaptor mockStartConnection() { } private void establishConnectedBillingClient( - @Nullable Map arguments, @Nullable Result result) { + @Nullable Map arguments, @Nullable Result result) { if (arguments == null) { arguments = new HashMap<>(); arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); } if (result == null) { result = mock(Result.class); @@ -489,7 +597,12 @@ private void queryForSkus(List skusList) { verify(mockBillingClient).querySkuDetailsAsync(any(), listenerCaptor.capture()); List skuDetailsResponse = skusList.stream().map(this::buildSkuDetails).collect(toList()); - listenerCaptor.getValue().onSkuDetailsResponse(BillingResponse.OK, skuDetailsResponse); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); } private SkuDetails buildSkuDetails(String id) { @@ -503,4 +616,10 @@ private Purchase buildPurchase(String orderId) { when(purchase.getOrderId()).thenReturn(orderId); return purchase; } + + private PurchaseHistoryRecord buildPurchaseHistoryRecord(String purchaseToken) { + PurchaseHistoryRecord purchase = mock(PurchaseHistoryRecord.class); + when(purchase.getPurchaseToken()).thenReturn(purchaseToken); + return purchase; + } } diff --git a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index 639af24a9732..2ee1044fe0c5 100644 --- a/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/example/android/app/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -8,9 +8,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.android.billingclient.api.BillingClient.BillingResponse; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.SkuDetails; import java.util.Arrays; import java.util.Collections; @@ -22,9 +24,9 @@ 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.\"}"; + "{\"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\"}"; + "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; @Test public void fromSkuDetail() throws JSONException { @@ -38,7 +40,7 @@ public void fromSkuDetail() throws JSONException { @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.\"}"; + "{\"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 = Arrays.asList( new SkuDetails(SKU_DETAIL_EXAMPLE_JSON), new SkuDetails(SKU_DETAIL_EXAMPLE_2_JSON)); @@ -58,14 +60,43 @@ public void fromSkuDetailsList_null() { @Test public void fromPurchase() throws JSONException { final Purchase expected = new Purchase(PURCHASE_EXAMPLE_JSON, "signature"); - assertSerialized(expected, Translator.fromPurchase(expected)); } + @Test + public void fromPurchaseHistoryRecord() throws JSONException { + final PurchaseHistoryRecord expected = + new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, "signature"); + assertSerialized(expected, Translator.fromPurchaseHistoryRecord(expected)); + } + + @Test + public void fromPurchasesHistoryRecordList() throws JSONException { + final String purchase2Json = + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + final String signature = "signature"; + final List expected = + Arrays.asList( + new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, signature), + new PurchaseHistoryRecord(purchase2Json, signature)); + + final List> serialized = + Translator.fromPurchaseHistoryRecordList(expected); + + assertEquals(expected.size(), serialized.size()); + assertSerialized(expected.get(0), serialized.get(0)); + assertSerialized(expected.get(1), serialized.get(1)); + } + + @Test + public void fromPurchasesHistoryRecordList_null() { + assertEquals(Collections.emptyList(), Translator.fromPurchaseHistoryRecordList(null)); + } + @Test public void fromPurchasesList() throws JSONException { final String purchase2Json = - "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\"}"; + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; final String signature = "signature"; final List expected = Arrays.asList( @@ -87,33 +118,54 @@ public void fromPurchasesList_null() { public void fromPurchasesResult() throws JSONException { PurchasesResult result = mock(PurchasesResult.class); final String purchase2Json = - "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\"}"; + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; final String signature = "signature"; final List expectedPurchases = Arrays.asList( new Purchase(PURCHASE_EXAMPLE_JSON, signature), new Purchase(purchase2Json, signature)); when(result.getPurchasesList()).thenReturn(expectedPurchases); - when(result.getResponseCode()).thenReturn(BillingResponse.OK); - + when(result.getResponseCode()).thenReturn(BillingClient.BillingResponseCode.OK); + BillingResult newBillingResult = + BillingResult.newBuilder() + .setDebugMessage("dummy debug message") + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(); + when(result.getBillingResult()).thenReturn(newBillingResult); final HashMap serialized = Translator.fromPurchasesResult(result); - assertEquals(BillingResponse.OK, serialized.get("responseCode")); + assertEquals(BillingClient.BillingResponseCode.OK, serialized.get("responseCode")); List> serializedPurchases = (List>) serialized.get("purchasesList"); assertEquals(expectedPurchases.size(), serializedPurchases.size()); assertSerialized(expectedPurchases.get(0), serializedPurchases.get(0)); assertSerialized(expectedPurchases.get(1), serializedPurchases.get(1)); + + Map billingResultMap = (Map) serialized.get("billingResult"); + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); } @Test - public void fromPurchasesResult_null() throws JSONException { - PurchasesResult result = mock(PurchasesResult.class); - when(result.getResponseCode()).thenReturn(BillingResponse.ERROR); + public void fromBillingResult() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder() + .setDebugMessage("dummy debug message") + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); + + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } - final HashMap serialized = Translator.fromPurchasesResult(result); + @Test + public void fromBillingResult_debugMessageNull() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); - assertEquals(BillingResponse.ERROR, serialized.get("responseCode")); - assertEquals(Collections.emptyList(), serialized.get("purchasesList")); + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); } private void assertSerialized(SkuDetails expected, Map serialized) { @@ -132,6 +184,9 @@ private void assertSerialized(SkuDetails expected, Map serialize 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")); } private void assertSerialized(Purchase expected, Map serialized) { @@ -142,5 +197,17 @@ private void assertSerialized(Purchase expected, Map serialized) assertEquals(expected.getSignature(), serialized.get("signature")); assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); + assertEquals(expected.isAcknowledged(), serialized.get("isAcknowledged")); + assertEquals(expected.getPurchaseState(), serialized.get("purchaseState")); + } + + private void assertSerialized(PurchaseHistoryRecord expected, Map serialized) { + assertEquals(expected.getPurchaseTime(), serialized.get("purchaseTime")); + assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); + assertEquals(expected.getSignature(), serialized.get("signature")); + assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); } } diff --git a/packages/in_app_purchase/example/lib/main.dart b/packages/in_app_purchase/example/lib/main.dart index 8d142c8c095b..826d2a121cdc 100644 --- a/packages/in_app_purchase/example/lib/main.dart +++ b/packages/in_app_purchase/example/lib/main.dart @@ -9,6 +9,10 @@ import 'package:in_app_purchase/in_app_purchase.dart'; import 'consumable_store.dart'; void main() { + // For play billing library 2.0 on Android, it is mandatory to call + // [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases) + // as part of initializing the app. + InAppPurchaseConnection.enablePendingPurchases(); runApp(MyApp()); } @@ -226,7 +230,7 @@ class _MyAppState extends State { // We recommend that you use your own server to verity the purchase data. Map purchases = Map.fromEntries(_purchases.map((PurchaseDetails purchase) { - if (Platform.isIOS) { + if (purchase.pendingCompletePurchase) { InAppPurchaseConnection.instance.completePurchase(purchase); } return MapEntry(purchase.productID, purchase); @@ -361,28 +365,32 @@ class _MyAppState extends State { void _listenToPurchaseUpdated(List purchaseDetailsList) { purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async { + await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails); if (purchaseDetails.status == PurchaseStatus.pending) { showPendingUI(); } else { if (purchaseDetails.status == PurchaseStatus.error) { handleError(purchaseDetails.error); + return; } else if (purchaseDetails.status == PurchaseStatus.purchased) { bool valid = await _verifyPurchase(purchaseDetails); if (valid) { deliverProduct(purchaseDetails); } else { _handleInvalidPurchase(purchaseDetails); + return; } } - if (Platform.isIOS) { - await InAppPurchaseConnection.instance - .completePurchase(purchaseDetails); - } else if (Platform.isAndroid) { + if (Platform.isAndroid) { if (!kAutoConsume && purchaseDetails.productID == _kConsumableId) { await InAppPurchaseConnection.instance .consumePurchase(purchaseDetails); } } + if (purchaseDetails.pendingCompletePurchase) { + await InAppPurchaseConnection.instance + .completePurchase(purchaseDetails); + } } }); } diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart index 6d7cd83eb0ad..ebbd90aba0f4 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../billing_client_wrappers.dart'; import '../channel.dart'; import 'purchase_wrapper.dart'; import 'sku_details_wrapper.dart'; @@ -48,6 +49,8 @@ typedef void PurchasesUpdatedListener(PurchasesResultWrapper purchasesResult); /// some minor changes to account for language differences. Callbacks have been /// converted to futures where appropriate. class BillingClient { + bool _enablePendingPurchases = false; + BillingClient(PurchasesUpdatedListener onPurchasesUpdated) { assert(onPurchasesUpdated != null); channel.setMethodCallHandler(callHandler); @@ -70,25 +73,43 @@ class BillingClient { Future isReady() async => await channel.invokeMethod('BillingClient#isReady()'); + /// Enable the [BillingClientWrapper] to handle pending purchases. + /// + /// Play requires that you call this method when initializing your application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// + /// Failure to call this method before any other method in the [startConnection] will throw an exception. + void enablePendingPurchases() { + _enablePendingPurchases = true; + } + /// Calls /// [`BillingClient#startConnection(BillingClientStateListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#startconnection) /// to create and connect a `BillingClient` instance. /// /// [onBillingServiceConnected] has been converted from a callback parameter /// to the Future result returned by this function. This returns the - /// `BillingClient.BillingResponse` `responseCode` of the connection result. + /// `BillingClient.BillingResultWrapper` describing the connection result. /// /// This triggers the creation of a new `BillingClient` instance in Java if /// one doesn't already exist. - Future startConnection( + Future startConnection( {@required OnBillingServiceDisconnected onBillingServiceDisconnected}) async { + assert(_enablePendingPurchases, + 'enablePendingPurchases() must be called before calling startConnection'); List disconnectCallbacks = _callbacks[_kOnBillingServiceDisconnected] ??= []; disconnectCallbacks.add(onBillingServiceDisconnected); - return BillingResponseConverter().fromJson(await channel.invokeMethod( - "BillingClient#startConnection(BillingClientStateListener)", - {'handle': disconnectCallbacks.length - 1})); + return BillingResultWrapper.fromJson(await channel + .invokeMapMethod( + "BillingClient#startConnection(BillingClientStateListener)", + { + 'handle': disconnectCallbacks.length - 1, + 'enablePendingPurchases': _enablePendingPurchases + })); } /// Calls @@ -134,7 +155,7 @@ class BillingClient { /// Calling this attemps to show the Google Play purchase UI. The user is free /// to complete the transaction there. /// - /// This method returns a [BillingResponse] representing the initial attempt + /// This method returns a [BillingResultWrapper] representing the initial attempt /// to show the Google Play billing flow. Actual purchase updates are /// delivered via the [PurchasesUpdatedListener]. /// @@ -146,16 +167,17 @@ class BillingClient { /// skuDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setskudetails) /// and [the given /// accountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setAccountId(java.lang.String)). - Future launchBillingFlow( + Future launchBillingFlow( {@required String sku, String accountId}) async { assert(sku != null); final Map arguments = { 'sku': sku, 'accountId': accountId, }; - return BillingResponseConverter().fromJson(await channel.invokeMethod( - 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)', - arguments)); + return BillingResultWrapper.fromJson( + await channel.invokeMapMethod( + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)', + arguments)); } /// Fetches recent purchases for the given [SkuType]. @@ -190,9 +212,9 @@ class BillingClient { /// This wraps [`BillingClient#queryPurchaseHistoryAsync(String skuType, /// PurchaseHistoryResponseListener /// listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchasehistoryasync). - Future queryPurchaseHistory(SkuType skuType) async { + Future queryPurchaseHistory(SkuType skuType) async { assert(skuType != null); - return PurchasesResultWrapper.fromJson(await channel.invokeMapMethod( 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)', {'skuType': SkuTypeConverter().toJson(skuType)})); @@ -201,15 +223,54 @@ class BillingClient { /// Consumes a given in-app product. /// /// Consuming can only be done on an item that's owned, and as a result of consumption, the user will no longer own it. - /// Consumption is done asynchronously. The method returns a Future containing a [BillingResponse]. + /// Consumption is done asynchronously. The method returns a Future containing a [BillingResultWrapper]. + /// + /// The `purchaseToken` must not be null. + /// The `developerPayload` is the developer data associated with the purchase to be consumed, it defaults to null. /// /// This wraps [`BillingClient#consumeAsync(String, ConsumeResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#consumeAsync(java.lang.String,%20com.android.billingclient.api.ConsumeResponseListener)) - Future consumeAsync(String purchaseToken) async { + Future consumeAsync(String purchaseToken, + {String developerPayload}) async { assert(purchaseToken != null); - return BillingResponseConverter().fromJson(await channel.invokeMethod( - 'BillingClient#consumeAsync(String, ConsumeResponseListener)', - {'purchaseToken': purchaseToken}, - )); + return BillingResultWrapper.fromJson(await channel + .invokeMapMethod( + 'BillingClient#consumeAsync(String, ConsumeResponseListener)', + { + 'purchaseToken': purchaseToken, + 'developerPayload': developerPayload, + })); + } + + /// Acknowledge an in-app purchase. + /// + /// The developer must acknowledge all in-app purchases after they have been granted to the user. + /// If this doesn't happen within three days of the purchase, the purchase will be refunded. + /// + /// Consumables are already implicitly acknowledged by calls to [consumeAsync] and + /// do not need to be explicitly acknowledged by using this method. + /// However this method can be called for them in order to explicitly acknowledge them if desired. + /// + /// Be sure to only acknowledge a purchase after it has been granted to the user. + /// [PurchaseWrapper.purchaseState] should be [PurchaseStateWrapper.purchased] and + /// the purchase should be validated. See [Verify a purchase](https://developer.android.com/google/play/billing/billing_library_overview#Verify) on verifying purchases. + /// + /// Please refer to [acknowledge](https://developer.android.com/google/play/billing/billing_library_overview#acknowledge) for more + /// details. + /// + /// The `purchaseToken` must not be null. + /// The `developerPayload` is the developer data associated with the purchase to be consumed, it defaults to null. + /// + /// This wraps [`BillingClient#acknowledgePurchase(String, AcknowledgePurchaseResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#acknowledgePurchase(com.android.billingclient.api.AcknowledgePurchaseParams,%20com.android.billingclient.api.AcknowledgePurchaseResponseListener)) + Future acknowledgePurchase(String purchaseToken, + {String developerPayload}) async { + assert(purchaseToken != null); + return BillingResultWrapper.fromJson(await channel.invokeMapMethod( + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)', + { + 'purchaseToken': purchaseToken, + 'developerPayload': developerPayload, + })); } @visibleForTesting diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart index 5d0522135d99..1e81895438c3 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:in_app_purchase/billing_client_wrappers.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:json_annotation/json_annotation.dart'; part 'enum_converters.g.dart'; @@ -42,4 +43,36 @@ class SkuTypeConverter implements JsonConverter { class _SerializedEnums { BillingResponse response; SkuType type; + PurchaseStateWrapper purchaseState; +} + +/// Serializer for [PurchaseStateWrapper]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@PurchaseStateConverter()`. +class PurchaseStateConverter + implements JsonConverter { + const PurchaseStateConverter(); + + @override + PurchaseStateWrapper fromJson(int json) => _$enumDecode( + _$PurchaseStateWrapperEnumMap.cast(), + json); + + @override + int toJson(PurchaseStateWrapper object) => + _$PurchaseStateWrapperEnumMap[object]; + + PurchaseStatus toPurchaseStatus(PurchaseStateWrapper object) { + switch (object) { + case PurchaseStateWrapper.pending: + return PurchaseStatus.pending; + case PurchaseStateWrapper.purchased: + return PurchaseStatus.purchased; + case PurchaseStateWrapper.unspecified_state: + return PurchaseStatus.error; + } + + throw ArgumentError('$object isn\'t mapped to PurchaseStatus'); + } } diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart index 0f549097ef32..899304b08273 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -9,13 +9,16 @@ part of 'enum_converters.dart'; _SerializedEnums _$_SerializedEnumsFromJson(Map json) { return _SerializedEnums() ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) - ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']); + ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) + ..purchaseState = + _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']); } Map _$_SerializedEnumsToJson(_SerializedEnums instance) => { 'response': _$BillingResponseEnumMap[instance.response], 'type': _$SkuTypeEnumMap[instance.type], + 'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState], }; T _$enumDecode( @@ -57,3 +60,9 @@ const _$SkuTypeEnumMap = { SkuType.inapp: 'inapp', SkuType.subs: 'subs', }; + +const _$PurchaseStateWrapperEnumMap = { + PurchaseStateWrapper.unspecified_state: 0, + PurchaseStateWrapper.purchased: 1, + PurchaseStateWrapper.pending: 2, +}; diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.dart index 30f8732904b7..0d4b74f41ab5 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -7,13 +7,14 @@ import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; import 'enum_converters.dart'; import 'billing_client_wrapper.dart'; +import 'sku_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 'purchase_wrapper.g.dart'; -/// Data structure reprenting a succesful purchase. +/// Data structure representing a successful purchase. /// /// All purchase information should also be verified manually, with your /// server if at all possible. See ["Verify a @@ -21,18 +22,21 @@ part 'purchase_wrapper.g.dart'; /// /// This wraps [`com.android.billlingclient.api.Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase) @JsonSerializable() +@PurchaseStateConverter() class PurchaseWrapper { @visibleForTesting - PurchaseWrapper({ - @required this.orderId, - @required this.packageName, - @required this.purchaseTime, - @required this.purchaseToken, - @required this.signature, - @required this.sku, - @required this.isAutoRenewing, - @required this.originalJson, - }); + PurchaseWrapper( + {@required this.orderId, + @required this.packageName, + @required this.purchaseTime, + @required this.purchaseToken, + @required this.signature, + @required this.sku, + @required this.isAutoRenewing, + @required this.originalJson, + @required this.developerPayload, + @required this.isAcknowledged, + @required this.purchaseState}); factory PurchaseWrapper.fromJson(Map map) => _$PurchaseWrapperFromJson(map); @@ -48,12 +52,23 @@ class PurchaseWrapper { typedOther.signature == signature && typedOther.sku == sku && typedOther.isAutoRenewing == isAutoRenewing && - typedOther.originalJson == originalJson; + typedOther.originalJson == originalJson && + typedOther.isAcknowledged == isAcknowledged && + typedOther.purchaseState == purchaseState; } @override - int get hashCode => hashValues(orderId, packageName, purchaseTime, - purchaseToken, signature, sku, isAutoRenewing, originalJson); + int get hashCode => hashValues( + orderId, + packageName, + purchaseTime, + purchaseToken, + signature, + sku, + isAutoRenewing, + originalJson, + isAcknowledged, + purchaseState); /// The unique ID for this purchase. Corresponds to the Google Payments order /// ID. @@ -89,11 +104,93 @@ class PurchaseWrapper { /// Note though that verifying a purchase locally is inherently insecure (see /// the article for more details). final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + final String developerPayload; + + /// Whether the purchase has been acknowledged. + /// + /// A successful purchase has to be acknowledged within 3 days after the purchase via [BillingClient.acknowledgePurchase]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + final bool isAcknowledged; + + /// Determines the current state of the purchase. + /// + /// [BillingClient.acknowledgePurchase] should only be called when the `purchaseState` is [PurchaseStateWrapper.purchased]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + final PurchaseStateWrapper purchaseState; +} + +/// Data structure representing a purchase history record. +/// +/// This class includes a subset of fields in [PurchaseWrapper]. +/// +/// This wraps [`com.android.billlingclient.api.PurchaseHistoryRecord`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord) +/// +/// * See also: [BillingClient.queryPurchaseHistory] for obtaining a [PurchaseHistoryRecordWrapper]. +// We can optionally make [PurchaseWrapper] extend or implement [PurchaseHistoryRecordWrapper]. +// For now, we keep them separated classes to be consistent with Android's BillingClient implementation. +@JsonSerializable() +class PurchaseHistoryRecordWrapper { + @visibleForTesting + PurchaseHistoryRecordWrapper({ + @required this.purchaseTime, + @required this.purchaseToken, + @required this.signature, + @required this.sku, + @required this.originalJson, + @required this.developerPayload, + }); + + factory PurchaseHistoryRecordWrapper.fromJson(Map map) => + _$PurchaseHistoryRecordWrapperFromJson(map); + + /// When the purchase was made, as an epoch timestamp. + final int purchaseTime; + + /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + final String purchaseToken; + + /// Signature of purchase data, signed with the developer's private key. Uses + /// RSASSA-PKCS1-v1_5. + final String signature; + + /// The product ID of this purchase. + final String sku; + + /// Details about this purchase, in JSON. + /// + /// This can be used verify a purchase. See ["Verify a purchase on a + /// device"](https://developer.android.com/google/play/billing/billing_library_overview#Verify-purchase-device). + /// Note though that verifying a purchase locally is inherently insecure (see + /// the article for more details). + final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + final String developerPayload; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchaseHistoryRecordWrapper typedOther = other; + return typedOther.purchaseTime == purchaseTime && + typedOther.purchaseToken == purchaseToken && + typedOther.signature == signature && + typedOther.sku == sku && + typedOther.originalJson == originalJson && + typedOther.developerPayload == developerPayload; + } + + @override + int get hashCode => hashValues(purchaseTime, purchaseToken, signature, sku, + originalJson, developerPayload); } /// A data struct representing the result of a transaction. /// -/// Contains a potentially empty list of [PurchaseWrapper]s and a +/// Contains a potentially empty list of [PurchaseWrapper]s, a [BillingResultWrapper] +/// that contains a detailed description of the status and a /// [BillingResponse] to signify the overall state of the transaction. /// /// Wraps [`com.android.billingclient.api.Purchase.PurchasesResult`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchasesResult). @@ -101,7 +198,9 @@ class PurchaseWrapper { @BillingResponseConverter() class PurchasesResultWrapper { PurchasesResultWrapper( - {@required this.responseCode, @required this.purchasesList}); + {@required this.responseCode, + @required this.billingResult, + @required this.purchasesList}); factory PurchasesResultWrapper.fromJson(Map map) => _$PurchasesResultWrapperFromJson(map); @@ -112,11 +211,15 @@ class PurchasesResultWrapper { if (other.runtimeType != runtimeType) return false; final PurchasesResultWrapper typedOther = other; return typedOther.responseCode == responseCode && - typedOther.purchasesList == purchasesList; + typedOther.purchasesList == purchasesList && + typedOther.billingResult == billingResult; } @override - int get hashCode => hashValues(responseCode, purchasesList); + int get hashCode => hashValues(billingResult, responseCode, purchasesList); + + /// The detailed description of the status of the operation. + final BillingResultWrapper billingResult; /// The status of the operation. /// @@ -124,8 +227,73 @@ class PurchasesResultWrapper { /// of the operation and the "user made purchases" transaction itself. final BillingResponse responseCode; - /// The list of succesful purchases made in this transaction. + /// The list of successful purchases made in this transaction. /// /// May be empty, especially if [responseCode] is not [BillingResponse.ok]. final List purchasesList; } + +/// A data struct representing the result of a purchase history. +/// +/// Contains a potentially empty list of [PurchaseHistoryRecordWrapper]s and a [BillingResultWrapper] +/// that contains a detailed description of the status. +@JsonSerializable() +@BillingResponseConverter() +class PurchasesHistoryResult { + PurchasesHistoryResult( + {@required this.billingResult, @required this.purchaseHistoryRecordList}); + + factory PurchasesHistoryResult.fromJson(Map map) => + _$PurchasesHistoryResultFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchasesHistoryResult typedOther = other; + return typedOther.purchaseHistoryRecordList == purchaseHistoryRecordList && + typedOther.billingResult == billingResult; + } + + @override + int get hashCode => hashValues(billingResult, purchaseHistoryRecordList); + + /// The detailed description of the status of the [BillingClient.queryPurchaseHistory]. + final BillingResultWrapper billingResult; + + /// The list of queried purchase history records. + /// + /// May be empty, especially if [billingResult.responseCode] is not [BillingResponse.ok]. + final List purchaseHistoryRecordList; +} + +/// Possible state of a [PurchaseWrapper]. +/// +/// Wraps +/// [`BillingClient.api.Purchase.PurchaseState`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState.html). +/// * See also: [PurchaseWrapper]. +enum PurchaseStateWrapper { + /// The state is unspecified. + /// + /// No actions on the [PurchaseWrapper] should be performed on this state. + /// This is a catch-all. It should never be returned by the Play Billing Library. + @JsonValue(0) + unspecified_state, + + /// The user has completed the purchase process. + /// + /// The production should be delivered and then the purchase should be acknowledged. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + @JsonValue(1) + purchased, + + /// The user has started the purchase process. + /// + /// The user should follow the instructions that were given to them by the Play + /// Billing Library to complete the purchase. + /// + /// You can also choose to remind the user to complete the purchase if you detected a + /// [PurchaseWrapper] is still in the `pending` state in the future while calling [BillingClient.queryPurchases]. + @JsonValue(2) + pending, +} diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.g.dart index 899e600e8bdb..3d555890b31e 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.g.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -16,6 +16,10 @@ PurchaseWrapper _$PurchaseWrapperFromJson(Map json) { sku: json['sku'] as String, isAutoRenewing: json['isAutoRenewing'] as bool, originalJson: json['originalJson'] as String, + developerPayload: json['developerPayload'] as String, + isAcknowledged: json['isAcknowledged'] as bool, + purchaseState: + const PurchaseStateConverter().fromJson(json['purchaseState'] as int), ); } @@ -29,11 +33,39 @@ Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => 'sku': instance.sku, 'isAutoRenewing': instance.isAutoRenewing, 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, + 'isAcknowledged': instance.isAcknowledged, + 'purchaseState': + const PurchaseStateConverter().toJson(instance.purchaseState), + }; + +PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) { + return PurchaseHistoryRecordWrapper( + purchaseTime: json['purchaseTime'] as int, + purchaseToken: json['purchaseToken'] as String, + signature: json['signature'] as String, + sku: json['sku'] as String, + originalJson: json['originalJson'] as String, + developerPayload: json['developerPayload'] as String, + ); +} + +Map _$PurchaseHistoryRecordWrapperToJson( + PurchaseHistoryRecordWrapper instance) => + { + 'purchaseTime': instance.purchaseTime, + 'purchaseToken': instance.purchaseToken, + 'signature': instance.signature, + 'sku': instance.sku, + 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, }; PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) { return PurchasesResultWrapper( - responseCode: _$enumDecode(_$BillingResponseEnumMap, json['responseCode']), + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int), + billingResult: BillingResultWrapper.fromJson(json['billingResult'] as Map), purchasesList: (json['purchasesList'] as List) .map((e) => PurchaseWrapper.fromJson(e as Map)) .toList(), @@ -43,41 +75,24 @@ PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) { Map _$PurchasesResultWrapperToJson( PurchasesResultWrapper instance) => { - 'responseCode': _$BillingResponseEnumMap[instance.responseCode], + 'billingResult': instance.billingResult, + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), 'purchasesList': instance.purchasesList, }; -T _$enumDecode( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - throw ArgumentError('A value must be provided. Supported values: ' - '${enumValues.values.join(', ')}'); - } - - final value = enumValues.entries - .singleWhere((e) => e.value == source, orElse: () => null) - ?.key; - - if (value == null && unknownValue == null) { - throw ArgumentError('`$source` is not one of the supported values: ' - '${enumValues.values.join(', ')}'); - } - return value ?? unknownValue; +PurchasesHistoryResult _$PurchasesHistoryResultFromJson(Map json) { + return PurchasesHistoryResult( + billingResult: BillingResultWrapper.fromJson(json['billingResult'] as Map), + purchaseHistoryRecordList: (json['purchaseHistoryRecordList'] as List) + .map((e) => PurchaseHistoryRecordWrapper.fromJson(e as Map)) + .toList(), + ); } -const _$BillingResponseEnumMap = { - BillingResponse.featureNotSupported: -2, - BillingResponse.serviceDisconnected: -1, - BillingResponse.ok: 0, - BillingResponse.userCanceled: 1, - BillingResponse.serviceUnavailable: 2, - BillingResponse.billingUnavailable: 3, - BillingResponse.itemUnavailable: 4, - BillingResponse.developerError: 5, - BillingResponse.error: 6, - BillingResponse.itemAlreadyOwned: 7, - BillingResponse.itemNotOwned: 8, -}; +Map _$PurchasesHistoryResultToJson( + PurchasesHistoryResult instance) => + { + 'billingResult': instance.billingResult, + 'purchaseHistoryRecordList': instance.purchaseHistoryRecordList, + }; diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.dart index 670bf5125491..4d6a9307a53a 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -35,6 +35,8 @@ class SkuDetailsWrapper { @required this.title, @required this.type, @required this.isRewarded, + @required this.originalPrice, + @required this.originalPriceAmountMicros, }); /// Constructs an instance of this from a key value map of data. @@ -84,6 +86,12 @@ class SkuDetailsWrapper { /// False if the product is paid. final bool isRewarded; + /// The original price that the user purchased this product for. + final String originalPrice; + + /// [originalPrice] in micro-units ("990000"). + final int originalPriceAmountMicros; + @override bool operator ==(dynamic other) { if (other.runtimeType != runtimeType) { @@ -104,7 +112,9 @@ class SkuDetailsWrapper { typedOther.subscriptionPeriod == subscriptionPeriod && typedOther.title == title && typedOther.type == type && - typedOther.isRewarded == isRewarded; + typedOther.isRewarded == isRewarded && + typedOther.originalPrice == originalPrice && + typedOther.originalPriceAmountMicros == originalPriceAmountMicros; } @override @@ -122,7 +132,9 @@ class SkuDetailsWrapper { subscriptionPeriod.hashCode, title.hashCode, type.hashCode, - isRewarded.hashCode); + isRewarded.hashCode, + originalPrice, + originalPriceAmountMicros); } } @@ -130,10 +142,10 @@ class SkuDetailsWrapper { /// /// Returned by [BillingClient.querySkuDetails]. @JsonSerializable() -@BillingResponseConverter() class SkuDetailsResponseWrapper { @visibleForTesting - SkuDetailsResponseWrapper({@required this.responseCode, this.skuDetailsList}); + SkuDetailsResponseWrapper( + {@required this.billingResult, this.skuDetailsList}); /// Constructs an instance of this from a key value map of data. /// @@ -142,8 +154,8 @@ class SkuDetailsResponseWrapper { factory SkuDetailsResponseWrapper.fromJson(Map map) => _$SkuDetailsResponseWrapperFromJson(map); - /// The final status of the [BillingClient.querySkuDetails] call. - final BillingResponse responseCode; + /// The final result of the [BillingClient.querySkuDetails] call. + final BillingResultWrapper billingResult; /// A list of [SkuDetailsWrapper] matching the query to [BillingClient.querySkuDetails]. final List skuDetailsList; @@ -156,10 +168,48 @@ class SkuDetailsResponseWrapper { final SkuDetailsResponseWrapper typedOther = other; return typedOther is SkuDetailsResponseWrapper && - typedOther.responseCode == responseCode && + typedOther.billingResult == billingResult && typedOther.skuDetailsList == skuDetailsList; } @override - int get hashCode => hashValues(responseCode, skuDetailsList); + int get hashCode => hashValues(billingResult, skuDetailsList); +} + +/// Params containing the response code and the debug message from the Play Billing API response. +@JsonSerializable() +@BillingResponseConverter() +class BillingResultWrapper { + /// Constructs the object with [responseCode] and [debugMessage]. + 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) => + _$BillingResultWrapperFromJson(map); + + /// Response code returned in the Play Billing API calls. + final BillingResponse responseCode; + + /// Debug message returned in the Play Billing API calls. + /// + /// This message uses an en-US locale and should not be shown to users. + final String debugMessage; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final BillingResultWrapper typedOther = other; + return typedOther is BillingResultWrapper && + typedOther.responseCode == responseCode && + typedOther.debugMessage == debugMessage; + } + + @override + int get hashCode => hashValues(responseCode, debugMessage); } diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index 52447cc46b0a..70bde9318f03 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -20,8 +20,10 @@ SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { sku: json['sku'] as String, subscriptionPeriod: json['subscriptionPeriod'] as String, title: json['title'] as String, - type: _$enumDecode(_$SkuTypeEnumMap, json['type']), + type: const SkuTypeConverter().fromJson(json['type'] as String), isRewarded: json['isRewarded'] as bool, + originalPrice: json['originalPrice'] as String, + originalPriceAmountMicros: json['originalPriceAmountMicros'] as int, ); } @@ -39,39 +41,15 @@ Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => 'sku': instance.sku, 'subscriptionPeriod': instance.subscriptionPeriod, 'title': instance.title, - 'type': _$SkuTypeEnumMap[instance.type], + 'type': const SkuTypeConverter().toJson(instance.type), 'isRewarded': instance.isRewarded, + 'originalPrice': instance.originalPrice, + 'originalPriceAmountMicros': instance.originalPriceAmountMicros, }; -T _$enumDecode( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - throw ArgumentError('A value must be provided. Supported values: ' - '${enumValues.values.join(', ')}'); - } - - final value = enumValues.entries - .singleWhere((e) => e.value == source, orElse: () => null) - ?.key; - - if (value == null && unknownValue == null) { - throw ArgumentError('`$source` is not one of the supported values: ' - '${enumValues.values.join(', ')}'); - } - return value ?? unknownValue; -} - -const _$SkuTypeEnumMap = { - SkuType.inapp: 'inapp', - SkuType.subs: 'subs', -}; - SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { return SkuDetailsResponseWrapper( - responseCode: _$enumDecode(_$BillingResponseEnumMap, json['responseCode']), + billingResult: BillingResultWrapper.fromJson(json['billingResult'] as Map), skuDetailsList: (json['skuDetailsList'] as List) .map((e) => SkuDetailsWrapper.fromJson(e as Map)) .toList(), @@ -81,20 +59,22 @@ SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { Map _$SkuDetailsResponseWrapperToJson( SkuDetailsResponseWrapper instance) => { - 'responseCode': _$BillingResponseEnumMap[instance.responseCode], + 'billingResult': instance.billingResult, 'skuDetailsList': instance.skuDetailsList, }; -const _$BillingResponseEnumMap = { - BillingResponse.featureNotSupported: -2, - BillingResponse.serviceDisconnected: -1, - BillingResponse.ok: 0, - BillingResponse.userCanceled: 1, - BillingResponse.serviceUnavailable: 2, - BillingResponse.billingUnavailable: 3, - BillingResponse.itemUnavailable: 4, - BillingResponse.developerError: 5, - BillingResponse.error: 6, - BillingResponse.itemAlreadyOwned: 7, - BillingResponse.itemNotOwned: 8, -}; +BillingResultWrapper _$BillingResultWrapperFromJson(Map json) { + return BillingResultWrapper( + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int), + debugMessage: json['debugMessage'] as String, + ); +} + +Map _$BillingResultWrapperToJson( + BillingResultWrapper instance) => + { + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), + 'debugMessage': instance.debugMessage, + }; diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart b/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart index 956eb09f6313..f5ab95c5e513 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/app_store_connection.dart @@ -62,13 +62,16 @@ class AppStoreConnection implements InAppPurchaseConnection { } @override - Future completePurchase(PurchaseDetails purchase) { - return _skPaymentQueueWrapper + Future completePurchase(PurchaseDetails purchase, + {String developerPayload}) async { + await _skPaymentQueueWrapper .finishTransaction(purchase.skPaymentTransaction); + return BillingResultWrapper(responseCode: BillingResponse.ok); } @override - Future consumePurchase(PurchaseDetails purchase) { + Future consumePurchase(PurchaseDetails purchase, + {String developerPayload}) { throw UnsupportedError('consume purchase is not available on Android'); } diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart b/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart index 96a3d0c556b4..f2cd87b0700b 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/google_play_connection.dart @@ -24,6 +24,9 @@ class GooglePlayConnection _purchaseUpdatedController .add(await _getPurchaseDetailsFromResult(resultWrapper)); }) { + if (InAppPurchaseConnection.enablePendingPurchase) { + billingClient.enablePendingPurchases(); + } _readyFuture = _connect(); WidgetsBinding.instance.addObserver(this); _purchaseUpdatedController = StreamController.broadcast(); @@ -50,10 +53,11 @@ class GooglePlayConnection @override Future buyNonConsumable({@required PurchaseParam purchaseParam}) async { - BillingResponse response = await billingClient.launchBillingFlow( - sku: purchaseParam.productDetails.id, - accountId: purchaseParam.applicationUserName); - return response == BillingResponse.ok; + BillingResultWrapper billingResultWrapper = + await billingClient.launchBillingFlow( + sku: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName); + return billingResultWrapper.responseCode == BillingResponse.ok; } @override @@ -66,14 +70,22 @@ class GooglePlayConnection } @override - Future completePurchase(PurchaseDetails purchase) { - throw UnsupportedError('complete purchase is not available on Android'); + Future completePurchase(PurchaseDetails purchase, + {String developerPayload}) async { + if (purchase.billingClientPurchase.isAcknowledged) { + return BillingResultWrapper(responseCode: BillingResponse.ok); + } + return await billingClient.acknowledgePurchase( + purchase.verificationData.serverVerificationData, + developerPayload: developerPayload); } @override - Future consumePurchase(PurchaseDetails purchase) { - return billingClient - .consumeAsync(purchase.verificationData.serverVerificationData); + Future consumePurchase(PurchaseDetails purchase, + {String developerPayload}) { + return billingClient.consumeAsync( + purchase.verificationData.serverVerificationData, + developerPayload: developerPayload); } @override @@ -90,9 +102,21 @@ class GooglePlayConnection exception = e; responses = [ PurchasesResultWrapper( - responseCode: BillingResponse.error, purchasesList: []), + responseCode: BillingResponse.error, + purchasesList: [], + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: e.details.toString(), + ), + ), PurchasesResultWrapper( - responseCode: BillingResponse.error, purchasesList: []) + responseCode: BillingResponse.error, + purchasesList: [], + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: e.details.toString(), + ), + ) ]; } @@ -188,10 +212,14 @@ class GooglePlayConnection responses = [ // ignore: invalid_use_of_visible_for_testing_member SkuDetailsResponseWrapper( - responseCode: BillingResponse.error, skuDetailsList: []), + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []), // ignore: invalid_use_of_visible_for_testing_member SkuDetailsResponseWrapper( - responseCode: BillingResponse.error, skuDetailsList: []) + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []) ]; } List productDetailsList = @@ -220,23 +248,18 @@ class GooglePlayConnection static Future> _getPurchaseDetailsFromResult( PurchasesResultWrapper resultWrapper) async { IAPError error; - PurchaseStatus status; - if (resultWrapper.responseCode == BillingResponse.ok) { - error = null; - status = PurchaseStatus.purchased; - } else { + if (resultWrapper.responseCode != BillingResponse.ok) { error = IAPError( source: IAPSource.GooglePlay, code: kPurchaseErrorCode, message: resultWrapper.responseCode.toString(), + details: resultWrapper.billingResult.debugMessage, ); - status = PurchaseStatus.error; } final List> purchases = resultWrapper.purchasesList.map((PurchaseWrapper purchase) { - return _maybeAutoConsumePurchase(PurchaseDetails.fromPurchase(purchase) - ..status = status - ..error = error); + return _maybeAutoConsumePurchase( + PurchaseDetails.fromPurchase(purchase)..error = error); }).toList(); if (purchases.isNotEmpty) { return Future.wait(purchases); @@ -260,14 +283,16 @@ class GooglePlayConnection return purchaseDetails; } - final BillingResponse consumedResponse = + final BillingResultWrapper billingResult = await instance.consumePurchase(purchaseDetails); + final BillingResponse consumedResponse = billingResult.responseCode; if (consumedResponse != BillingResponse.ok) { purchaseDetails.status = PurchaseStatus.error; purchaseDetails.error = IAPError( source: IAPSource.GooglePlay, code: kConsumptionFailedErrorCode, message: consumedResponse.toString(), + details: billingResult.debugMessage, ); } _productIdsToConsume.remove(purchaseDetails.productID); diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/in_app_purchase_connection.dart b/packages/in_app_purchase/lib/src/in_app_purchase/in_app_purchase_connection.dart index 73990e86d9bd..4c4953d1ce98 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/in_app_purchase_connection.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/in_app_purchase_connection.dart @@ -11,6 +11,8 @@ import 'package:flutter/foundation.dart'; import 'package:in_app_purchase/billing_client_wrappers.dart'; import './purchase_details.dart'; +export 'package:in_app_purchase/billing_client_wrappers.dart'; + /// Basic API for making in app purchases across multiple platforms. /// /// This is a generic abstraction built from `billing_client_wrapers` and @@ -58,9 +60,28 @@ abstract class InAppPurchaseConnection { return _purchaseUpdatedStream; } + /// Whether pending purchase is enabled. + /// + /// See also [enablePendingPurchases] for more on pending purchases. + static bool get enablePendingPurchase => _enablePendingPurchase; + static bool _enablePendingPurchase = false; + /// Returns true if the payment platform is ready and available. Future isAvailable(); + /// Enable the [InAppPurchaseConnection] to handle pending purchases. + /// + /// Android Only: This method is required to be called when initialize the application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// Failure to call this method before access [instance] will throw an exception. + /// + /// It is an no-op on iOS. + static void enablePendingPurchases() { + _enablePendingPurchase = true; + } + /// Query product details for the given set of IDs. /// /// The [identifiers] need to exactly match existing configured product @@ -92,7 +113,7 @@ abstract class InAppPurchaseConnection { /// purchasing process. /// /// This method does return whether or not the purchase request was initially - /// sent succesfully. + /// sent successfully. /// /// Consumable items are defined differently by the different underlying /// payment platforms, and there's no way to query for whether or not the @@ -168,16 +189,27 @@ abstract class InAppPurchaseConnection { Future buyConsumable( {@required PurchaseParam purchaseParam, bool autoConsume = true}); - /// (App Store only) Mark that purchased content has been delivered to the + /// Mark that purchased content has been delivered to the /// user. /// /// You are responsible for completing every [PurchaseDetails] whose - /// [PurchaseDetails.status] is [PurchaseStatus.purchased] or - /// [[PurchaseStatus.error]. Completing a [PurchaseStatus.pending] purchase - /// will cause an exception. - /// - /// This throws an [UnsupportedError] on Android. - Future completePurchase(PurchaseDetails purchase); + /// [PurchaseDetails.status] is [PurchaseStatus.purchased]. Additionally on iOS, + /// the purchase needs to be completed if the [PurchaseDetails.status] is [PurchaseStatus.error]. + /// Completing a [PurchaseStatus.pending] purchase will cause an exception. + /// For convenience, [PurchaseDetails.pendingCompletePurchase] indicates if a purchase is pending for completion. + /// + /// The method returns a [BillingResultWrapper] to indicate a detailed status of the complete process. + /// If the result contains [BillingResponse.error] or [BillingResponse.serviceUnavailable], the developer should try + /// to complete the purchase via this method again, or retry the [completePurchase] it at a later time. + /// If the result indicates other errors, there might be some issue with + /// the app's code. The developer is responsible to fix the issue. + /// + /// Warning! Failure to call this method and get a successful response within 3 days of the purchase will result a refund on Android. + /// The [consumePurchase] acts as an implicit [completePurchase] on Android. + /// + /// The optional parameter `developerPayload` only works on Android. + Future completePurchase(PurchaseDetails purchase, + {String developerPayload}); /// (Play only) Mark that the user has consumed a product. /// @@ -185,8 +217,11 @@ abstract class InAppPurchaseConnection { /// delivered. The user won't be able to buy the same product again until the /// purchase of the product is consumed. /// + /// The `developerPayload` can be specified to be associated with this consumption. + /// /// This throws an [UnsupportedError] on iOS. - Future consumePurchase(PurchaseDetails purchase); + Future consumePurchase(PurchaseDetails purchase, + {String developerPayload}); /// Query all previous purchases. /// diff --git a/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart b/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart index 799420972dbe..375b48ad61e5 100644 --- a/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart +++ b/packages/in_app_purchase/lib/src/in_app_purchase/purchase_details.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/src/billing_client_wrappers/enum_converters.dart'; import 'package:in_app_purchase/src/billing_client_wrappers/purchase_wrapper.dart'; +import 'package:in_app_purchase/src/store_kit_wrappers/enum_converters.dart'; import 'package:in_app_purchase/src/store_kit_wrappers/sk_payment_transaction_wrappers.dart'; import './in_app_purchase_connection.dart'; import './product_details.dart'; @@ -11,6 +13,8 @@ import './product_details.dart'; final String kPurchaseErrorCode = 'purchase_error'; final String kRestoredPurchaseErrorCode = 'restore_transactions_failed'; final String kConsumptionFailedErrorCode = 'consume_purchase_failed'; +final String _kPlatformIOS = 'ios'; +final String _kPlatformAndroid = 'android'; /// Represents the data that is used to verify purchases. /// @@ -122,7 +126,23 @@ class PurchaseDetails { final String transactionDate; /// The status that this [PurchaseDetails] is currently on. - PurchaseStatus status; + PurchaseStatus get status => _status; + set status(PurchaseStatus status) { + if (_platform == _kPlatformIOS) { + if (status == PurchaseStatus.purchased || + status == PurchaseStatus.error) { + _pendingCompletePurchase = true; + } + } + if (_platform == _kPlatformAndroid) { + if (status == PurchaseStatus.purchased) { + _pendingCompletePurchase = true; + } + } + _status = status; + } + + PurchaseStatus _status; /// The error is only available when [status] is [PurchaseStatus.error]. IAPError error; @@ -137,6 +157,19 @@ class PurchaseDetails { /// This is null on iOS. final PurchaseWrapper billingClientPurchase; + /// The developer has to call [InAppPurchaseConnection.completePurchase] if the value is `true` + /// and the product has been delivered to the user. + /// + /// The initial value is `false`. + /// * See also [InAppPurchaseConnection.completePurchase] for more details on completing purchases. + bool get pendingCompletePurchase => _pendingCompletePurchase; + bool _pendingCompletePurchase = false; + + // The platform that the object is created on. + // + // The value is either '_kPlatformIOS' or '_kPlatformAndroid'. + String _platform; + PurchaseDetails({ @required this.purchaseID, @required this.productID, @@ -159,7 +192,10 @@ class PurchaseDetails { ? (transaction.transactionTimeStamp * 1000).toInt().toString() : null, this.skPaymentTransaction = transaction, - this.billingClientPurchase = null; + this.billingClientPurchase = null, + _status = SKTransactionStatusConverter() + .toPurchaseStatus(transaction.transactionState), + _platform = _kPlatformIOS; /// Generate a [PurchaseDetails] object based on an Android [Purchase] object. PurchaseDetails.fromPurchase(PurchaseWrapper purchase) @@ -171,7 +207,10 @@ class PurchaseDetails { source: IAPSource.GooglePlay), this.transactionDate = purchase.purchaseTime.toString(), this.skPaymentTransaction = null, - this.billingClientPurchase = purchase; + this.billingClientPurchase = purchase, + _status = + PurchaseStateConverter().toPurchaseStatus(purchase.purchaseState), + _platform = _kPlatformAndroid; } /// The response object for fetching the past purchases. diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart index cb21a01d62d8..bc520826d9fe 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_payment_transaction_wrappers.g.dart @@ -11,8 +11,8 @@ SKPaymentTransactionWrapper _$SKPaymentTransactionWrapperFromJson(Map json) { payment: json['payment'] == null ? null : SKPaymentWrapper.fromJson(json['payment'] as Map), - transactionState: _$enumDecodeNullable( - _$SKPaymentTransactionStateWrapperEnumMap, json['transactionState']), + transactionState: const SKTransactionStatusConverter() + .fromJson(json['transactionState'] as int), originalTransaction: json['originalTransaction'] == null ? null : SKPaymentTransactionWrapper.fromJson( @@ -27,51 +27,11 @@ SKPaymentTransactionWrapper _$SKPaymentTransactionWrapperFromJson(Map json) { Map _$SKPaymentTransactionWrapperToJson( SKPaymentTransactionWrapper instance) => { - 'transactionState': - _$SKPaymentTransactionStateWrapperEnumMap[instance.transactionState], + 'transactionState': const SKTransactionStatusConverter() + .toJson(instance.transactionState), 'payment': instance.payment, 'originalTransaction': instance.originalTransaction, 'transactionTimeStamp': instance.transactionTimeStamp, 'transactionIdentifier': instance.transactionIdentifier, 'error': instance.error, }; - -T _$enumDecode( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - throw ArgumentError('A value must be provided. Supported values: ' - '${enumValues.values.join(', ')}'); - } - - final value = enumValues.entries - .singleWhere((e) => e.value == source, orElse: () => null) - ?.key; - - if (value == null && unknownValue == null) { - throw ArgumentError('`$source` is not one of the supported values: ' - '${enumValues.values.join(', ')}'); - } - return value ?? unknownValue; -} - -T _$enumDecodeNullable( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - return null; - } - return _$enumDecode(enumValues, source, unknownValue: unknownValue); -} - -const _$SKPaymentTransactionStateWrapperEnumMap = { - SKPaymentTransactionStateWrapper.purchasing: 0, - SKPaymentTransactionStateWrapper.purchased: 1, - SKPaymentTransactionStateWrapper.failed: 2, - SKPaymentTransactionStateWrapper.restored: 3, - SKPaymentTransactionStateWrapper.deferred: 4, -}; diff --git a/packages/in_app_purchase/pubspec.yaml b/packages/in_app_purchase/pubspec.yaml index dd94ffd7beed..f8267582a1e1 100644 --- a/packages/in_app_purchase/pubspec.yaml +++ b/packages/in_app_purchase/pubspec.yaml @@ -1,7 +1,7 @@ name: in_app_purchase description: A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play. homepage: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase -version: 0.2.2+6 +version: 0.3.0 dependencies: diff --git a/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart index 818250607ed7..54f7c3eda77f 100644 --- a/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -23,6 +23,7 @@ void main() { setUp(() { billingClient = BillingClient((PurchasesResultWrapper _) {}); + billingClient.enablePendingPurchases(); stubPlatform.reset(); }); @@ -39,25 +40,43 @@ void main() { }); group('startConnection', () { - test('returns BillingResponse', () async { + final String methodName = + 'BillingClient#startConnection(BillingClientStateListener)'; + test('returns BillingResultWrapper', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; stubPlatform.addResponse( - name: 'BillingClient#startConnection(BillingClientStateListener)', - value: BillingResponseConverter().toJson(BillingResponse.ok)); + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); expect( await billingClient.startConnection( onBillingServiceDisconnected: () {}), - equals(BillingResponse.ok)); + equals(billingResult)); }); test('passes handle to onBillingServiceDisconnected', () async { - final String methodName = - 'BillingClient#startConnection(BillingClientStateListener)'; + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; stubPlatform.addResponse( - name: methodName, - value: BillingResponseConverter().toJson(BillingResponse.ok)); + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); await billingClient.startConnection(onBillingServiceDisconnected: () {}); final MethodCall call = stubPlatform.previousCallMatching(methodName); - expect(call.arguments, equals({'handle': 0})); + expect( + call.arguments, + equals( + {'handle': 0, 'enablePendingPurchases': true})); }); }); @@ -74,9 +93,13 @@ void main() { 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; test('handles empty skuDetails', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.developerError; stubPlatform.addResponse(name: queryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, 'skuDetailsList': >[] }); @@ -84,14 +107,20 @@ void main() { .querySkuDetails( skuType: SkuType.inapp, skusList: ['invalid']); - expect(response.responseCode, equals(responseCode)); + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); expect(response.skuDetailsList, isEmpty); }); test('returns SkuDetailsResponseWrapper', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; stubPlatform.addResponse(name: queryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] }); @@ -99,7 +128,9 @@ void main() { .querySkuDetails( skuType: SkuType.inapp, skusList: ['invalid']); - expect(response.responseCode, equals(responseCode)); + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); expect(response.skuDetailsList, contains(dummySkuDetails)); }); }); @@ -109,17 +140,21 @@ void main() { 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)'; test('serializes and deserializes data', () async { - final BillingResponse sentCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse( - name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode)); + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; - final BillingResponse receivedCode = await billingClient - .launchBillingFlow(sku: skuDetails.sku, accountId: accountId); - - expect(receivedCode, equals(sentCode)); + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, accountId: accountId), + equals(expectedBillingResult)); Map arguments = stubPlatform.previousCallMatching(launchMethodName).arguments; expect(arguments['sku'], equals(skuDetails.sku)); @@ -127,16 +162,18 @@ void main() { }); test('handles null accountId', () async { - final BillingResponse sentCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse( - name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode)); + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); final SkuDetailsWrapper skuDetails = dummySkuDetails; - final BillingResponse receivedCode = - await billingClient.launchBillingFlow(sku: skuDetails.sku); - - expect(receivedCode, equals(sentCode)); + expect(await billingClient.launchBillingFlow(sku: skuDetails.sku), + equals(expectedBillingResult)); Map arguments = stubPlatform.previousCallMatching(launchMethodName).arguments; expect(arguments['sku'], equals(skuDetails.sku)); @@ -153,8 +190,12 @@ void main() { final List expectedList = [ dummyPurchase ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(expectedCode), 'purchasesList': expectedList .map((PurchaseWrapper purchase) => buildPurchaseMap(purchase)) @@ -164,6 +205,7 @@ void main() { final PurchasesResultWrapper response = await billingClient.queryPurchases(SkuType.inapp); + expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); expect(response.purchasesList, equals(expectedList)); }); @@ -174,8 +216,12 @@ void main() { test('handles empty purchases', () async { final BillingResponse expectedCode = BillingResponse.userCanceled; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(expectedCode), 'purchasesList': [], }); @@ -183,6 +229,7 @@ void main() { final PurchasesResultWrapper response = await billingClient.queryPurchases(SkuType.inapp); + expect(response.billingResult, equals(expectedBillingResult)); expect(response.responseCode, equals(expectedCode)); expect(response.purchasesList, isEmpty); }); @@ -194,23 +241,27 @@ void main() { test('serializes and deserializes data', () async { final BillingResponse expectedCode = BillingResponse.ok; - final List expectedList = [ - dummyPurchase + final List expectedList = + [ + dummyPurchaseHistoryRecord, ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( name: queryPurchaseHistoryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(expectedCode), - 'purchasesList': expectedList - .map((PurchaseWrapper purchase) => buildPurchaseMap(purchase)) + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': expectedList + .map((PurchaseHistoryRecordWrapper purchaseHistoryRecord) => + buildPurchaseHistoryRecordMap(purchaseHistoryRecord)) .toList(), }); - final PurchasesResultWrapper response = + final PurchasesHistoryResult response = await billingClient.queryPurchaseHistory(SkuType.inapp); - - expect(response.responseCode, equals(expectedCode)); - expect(response.purchasesList, equals(expectedList)); + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, equals(expectedList)); }); test('checks for null params', () async { @@ -220,18 +271,19 @@ void main() { test('handles empty purchases', () async { final BillingResponse expectedCode = BillingResponse.userCanceled; - stubPlatform.addResponse( - name: queryPurchaseHistoryMethodName, - value: { - 'responseCode': BillingResponseConverter().toJson(expectedCode), - 'purchasesList': [], - }); + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryPurchaseHistoryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': [], + }); - final PurchasesResultWrapper response = + final PurchasesHistoryResult response = await billingClient.queryPurchaseHistory(SkuType.inapp); - expect(response.responseCode, equals(expectedCode)); - expect(response.purchasesList, isEmpty); + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, isEmpty); }); }); @@ -240,14 +292,37 @@ void main() { 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; test('consume purchase async success', () async { final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( name: consumeMethodName, - value: BillingResponseConverter().toJson(expectedCode)); + value: buildBillingResultMap(expectedBillingResult)); + + final BillingResultWrapper billingResult = await billingClient + .consumeAsync('dummy token', developerPayload: 'dummy payload'); + + expect(billingResult, equals(expectedBillingResult)); + }); + }); + + group('acknowledge purchases', () { + const String acknowledgeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('acknowledge purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: acknowledgeMethodName, + value: buildBillingResultMap(expectedBillingResult)); - final BillingResponse responseCode = - await billingClient.consumeAsync('dummy token'); + final BillingResultWrapper billingResult = + await billingClient.acknowledgePurchase('dummy token', + developerPayload: 'dummy payload'); - expect(responseCode, equals(expectedCode)); + expect(billingResult, equals(expectedBillingResult)); }); }); } diff --git a/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart index f1865b41842f..6f65bdc9788d 100644 --- a/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart +++ b/packages/in_app_purchase/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -17,6 +17,33 @@ final PurchaseWrapper dummyPurchase = PurchaseWrapper( purchaseToken: 'purchaseToken', isAutoRenewing: false, originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: false, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = + PurchaseHistoryRecordWrapper( + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + originalJson: '', + developerPayload: 'dummy payload', ); void main() { @@ -45,6 +72,17 @@ void main() { }); }); + group('PurchaseHistoryRecordWrapper', () { + test('converts from map', () { + final PurchaseHistoryRecordWrapper expected = dummyPurchaseHistoryRecord; + final PurchaseHistoryRecordWrapper parsed = + PurchaseHistoryRecordWrapper.fromJson( + buildPurchaseHistoryRecordMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + group('PurchasesResultWrapper', () { test('parsed from map', () { final BillingResponse responseCode = BillingResponse.ok; @@ -52,22 +90,55 @@ void main() { dummyPurchase, dummyPurchase ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); final PurchasesResultWrapper expected = PurchasesResultWrapper( - responseCode: responseCode, purchasesList: purchases); - + billingResult: billingResult, + responseCode: responseCode, + purchasesList: purchases); final PurchasesResultWrapper parsed = PurchasesResultWrapper.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), 'responseCode': BillingResponseConverter().toJson(responseCode), 'purchasesList': >[ buildPurchaseMap(dummyPurchase), buildPurchaseMap(dummyPurchase) ] }); - + expect(parsed.billingResult, equals(expected.billingResult)); expect(parsed.responseCode, equals(expected.responseCode)); expect(parsed.purchasesList, containsAll(expected.purchasesList)); }); }); + + group('PurchasesHistoryResult', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + final List purchaseHistoryRecordList = + [ + dummyPurchaseHistoryRecord, + dummyPurchaseHistoryRecord + ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final PurchasesHistoryResult expected = PurchasesHistoryResult( + billingResult: billingResult, + purchaseHistoryRecordList: purchaseHistoryRecordList); + final PurchasesHistoryResult parsed = + PurchasesHistoryResult.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), + 'purchaseHistoryRecordList': >[ + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord), + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord) + ] + }); + expect(parsed.billingResult, equals(billingResult)); + expect(parsed.purchaseHistoryRecordList, + containsAll(expected.purchaseHistoryRecordList)); + }); + }); } Map buildPurchaseMap(PurchaseWrapper original) { @@ -80,5 +151,27 @@ Map buildPurchaseMap(PurchaseWrapper original) { 'purchaseToken': original.purchaseToken, 'isAutoRenewing': original.isAutoRenewing, 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + 'purchaseState': PurchaseStateConverter().toJson(original.purchaseState), + 'isAcknowledged': original.isAcknowledged, + }; +} + +Map buildPurchaseHistoryRecordMap( + PurchaseHistoryRecordWrapper original) { + return { + 'purchaseTime': original.purchaseTime, + 'signature': original.signature, + 'sku': original.sku, + 'purchaseToken': original.purchaseToken, + 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + }; +} + +Map buildBillingResultMap(BillingResultWrapper original) { + return { + 'responseCode': BillingResponseConverter().toJson(original.responseCode), + 'debugMessage': original.debugMessage, }; } diff --git a/packages/in_app_purchase/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/test/billing_client_wrappers/sku_details_wrapper_test.dart index ace2f41b886a..c305e6df88cc 100644 --- a/packages/in_app_purchase/test/billing_client_wrappers/sku_details_wrapper_test.dart +++ b/packages/in_app_purchase/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -4,8 +4,8 @@ import 'package:test/test.dart'; import 'package:in_app_purchase/billing_client_wrappers.dart'; -import 'package:in_app_purchase/src/billing_client_wrappers/enum_converters.dart'; import 'package:in_app_purchase/src/in_app_purchase/product_details.dart'; +import 'package:in_app_purchase/src/billing_client_wrappers/enum_converters.dart'; final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( description: 'description', @@ -22,6 +22,8 @@ final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( title: 'title', type: SkuType.inapp, isRewarded: true, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, ); void main() { @@ -38,23 +40,29 @@ void main() { group('SkuDetailsResponseWrapper', () { test('parsed from map', () { final BillingResponse responseCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; final List skusDetails = [ dummySkuDetails, dummySkuDetails ]; + BillingResultWrapper result = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( - responseCode: responseCode, skuDetailsList: skusDetails); + billingResult: result, skuDetailsList: skusDetails); final SkuDetailsResponseWrapper parsed = SkuDetailsResponseWrapper.fromJson({ - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, 'skuDetailsList': >[ buildSkuMap(dummySkuDetails), buildSkuMap(dummySkuDetails) ] }); - expect(parsed.responseCode, equals(expected.responseCode)); + expect(parsed.billingResult, equals(expected.billingResult)); expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); }); @@ -72,17 +80,23 @@ void main() { test('handles empty list of skuDetails', () { final BillingResponse responseCode = BillingResponse.error; + const String debugMessage = 'dummy message'; final List skusDetails = []; + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( - responseCode: responseCode, skuDetailsList: skusDetails); + billingResult: billingResult, skuDetailsList: skusDetails); final SkuDetailsResponseWrapper parsed = SkuDetailsResponseWrapper.fromJson({ - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, 'skuDetailsList': >[] }); - expect(parsed.responseCode, equals(expected.responseCode)); + expect(parsed.billingResult, equals(expected.billingResult)); expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); }); }); @@ -104,5 +118,7 @@ Map buildSkuMap(SkuDetailsWrapper original) { 'title': original.title, 'type': original.type.toString().substring(8), 'isRewarded': original.isRewarded, + 'originalPrice': original.originalPrice, + 'originalPriceAmountMicros': original.originalPriceAmountMicros, }; } diff --git a/packages/in_app_purchase/test/in_app_purchase_connection/app_store_connection_test.dart b/packages/in_app_purchase/test/in_app_purchase_connection/app_store_connection_test.dart index ae24167b6a7c..9f963c4c99b7 100644 --- a/packages/in_app_purchase/test/in_app_purchase_connection/app_store_connection_test.dart +++ b/packages/in_app_purchase/test/in_app_purchase_connection/app_store_connection_test.dart @@ -245,7 +245,7 @@ void main() { subscription = stream.listen((purchaseDetailsList) { details.addAll(purchaseDetailsList); purchaseDetailsList.forEach((purchaseDetails) { - if (purchaseDetails.status == PurchaseStatus.purchased) { + if (purchaseDetails.pendingCompletePurchase) { AppStoreConnection.instance.completePurchase(purchaseDetails); completer.complete(details); subscription.cancel(); diff --git a/packages/in_app_purchase/test/in_app_purchase_connection/google_play_connection_test.dart b/packages/in_app_purchase/test/in_app_purchase_connection/google_play_connection_test.dart index 512664a24af0..dfad32d97c06 100644 --- a/packages/in_app_purchase/test/in_app_purchase_connection/google_play_connection_test.dart +++ b/packages/in_app_purchase/test/in_app_purchase_connection/google_play_connection_test.dart @@ -35,10 +35,15 @@ void main() { setUp(() { WidgetsFlutterBinding.ensureInitialized(); + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse( name: startConnectionCall, - value: BillingResponseConverter().toJson(BillingResponse.ok)); + value: buildBillingResultMap(expectedBillingResult)); stubPlatform.addResponse(name: endConnectionCall, value: null); + InAppPurchaseConnection.enablePendingPurchases(); connection = GooglePlayConnection.instance; }); @@ -82,10 +87,13 @@ void main() { 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; test('handles empty skuDetails', () async { - final BillingResponse responseCode = BillingResponse.developerError; - stubPlatform.addResponse(name: queryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(responseCode), - 'skuDetailsList': >[] + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': [], }); final ProductDetailsResponse response = @@ -94,9 +102,12 @@ void main() { }); test('should get correct product details', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse(name: queryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': buildBillingResultMap(expectedBillingResult), 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] }); // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead @@ -110,9 +121,12 @@ void main() { }); test('should get the correct notFoundIDs', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse(name: queryMethodName, value: { - 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': buildBillingResultMap(expectedBillingResult), 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] }); // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead @@ -157,8 +171,13 @@ void main() { group('queryPurchaseDetails', () { const String queryMethodName = 'BillingClient#queryPurchases(String)'; test('handles error', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(responseCode), 'purchasesList': >[] }); @@ -170,8 +189,13 @@ void main() { }); test('returns SkuDetailsResponseWrapper', () async { + const String debugMessage = 'dummy message'; final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(responseCode), 'purchasesList': >[ buildPurchaseMap(dummyPurchase), @@ -187,11 +211,16 @@ void main() { }); test('should store platform exception in the response', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); stubPlatform.addResponse( name: queryMethodName, value: { 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': buildBillingResultMap(expectedBillingResult), 'purchasesList': >[] }, additionalStepBeforeReturn: (_) { @@ -225,13 +254,18 @@ void main() { test('buy non consumable, serializes and deserializes data', () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode), + value: buildBillingResultMap(expectedBillingResult), additionalStepBeforeReturn: (_) { // Mock java update purchase callback. MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(sentCode), 'purchasesList': [ { @@ -242,7 +276,10 @@ void main() { 'purchaseTime': 1231231231, 'purchaseToken': "token", 'signature': 'sign', - 'originalJson': 'json' + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, } ] }); @@ -274,13 +311,18 @@ void main() { test('handles an error with an empty purchases list', () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode), + value: buildBillingResultMap(expectedBillingResult), additionalStepBeforeReturn: (_) { // Mock java update purchase callback. MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(sentCode), 'purchasesList': [] }); @@ -313,13 +355,18 @@ void main() { () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode), + value: buildBillingResultMap(expectedBillingResult), additionalStepBeforeReturn: (_) { // Mock java update purchase callback. MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(sentCode), 'purchasesList': [ { @@ -330,7 +377,10 @@ void main() { 'purchaseTime': 1231231231, 'purchaseToken': "token", 'signature': 'sign', - 'originalJson': 'json' + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, } ] }); @@ -339,9 +389,12 @@ void main() { Completer consumeCompleter = Completer(); // adding call back for consume purchase final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( name: consumeMethodName, - value: BillingResponseConverter().toJson(expectedCode), + value: buildBillingResultMap(expectedBillingResultForConsume), additionalStepBeforeReturn: (dynamic args) { String purchaseToken = args['purchaseToken']; consumeCompleter.complete((purchaseToken)); @@ -374,10 +427,13 @@ void main() { test('buyNonConsumable propagates failures to launch the billing flow', () async { + const String debugMessage = 'dummy message'; final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode)); + value: buildBillingResultMap(expectedBillingResult)); final bool result = await GooglePlayConnection.instance.buyNonConsumable( purchaseParam: PurchaseParam( @@ -389,10 +445,14 @@ void main() { test('buyConsumable propagates failures to launch the billing flow', () async { - final BillingResponse sentCode = BillingResponse.error; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); stubPlatform.addResponse( - name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode)); + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); final bool result = await GooglePlayConnection.instance.buyConsumable( purchaseParam: PurchaseParam( @@ -405,13 +465,17 @@ void main() { test('adds consumption failures to PurchaseDetails objects', () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode), + value: buildBillingResultMap(expectedBillingResult), additionalStepBeforeReturn: (_) { // Mock java update purchase callback. MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(sentCode), 'purchasesList': [ { @@ -422,7 +486,10 @@ void main() { 'purchaseTime': 1231231231, 'purchaseToken': "token", 'signature': 'sign', - 'originalJson': 'json' + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, } ] }); @@ -431,12 +498,15 @@ void main() { Completer consumeCompleter = Completer(); // adding call back for consume purchase final BillingResponse expectedCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( name: consumeMethodName, - value: BillingResponseConverter().toJson(expectedCode), + value: buildBillingResultMap(expectedBillingResultForConsume), additionalStepBeforeReturn: (dynamic args) { String purchaseToken = args['purchaseToken']; - consumeCompleter.complete((purchaseToken)); + consumeCompleter.complete(purchaseToken); }); Completer completer = Completer(); @@ -469,13 +539,18 @@ void main() { () async { final SkuDetailsWrapper skuDetails = dummySkuDetails; final String accountId = "hashedAccountId"; - final BillingResponse sentCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( name: launchMethodName, - value: BillingResponseConverter().toJson(sentCode), + value: buildBillingResultMap(expectedBillingResult), additionalStepBeforeReturn: (_) { // Mock java update purchase callback. MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), 'responseCode': BillingResponseConverter().toJson(sentCode), 'purchasesList': [ { @@ -486,7 +561,10 @@ void main() { 'purchaseTime': 1231231231, 'purchaseToken': "token", 'signature': 'sign', - 'originalJson': 'json' + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, } ] }); @@ -495,9 +573,12 @@ void main() { Completer consumeCompleter = Completer(); // adding call back for consume purchase final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( name: consumeMethodName, - value: BillingResponseConverter().toJson(expectedCode), + value: buildBillingResultMap(expectedBillingResultForConsume), additionalStepBeforeReturn: (dynamic args) { String purchaseToken = args['purchaseToken']; consumeCompleter.complete((purchaseToken)); @@ -524,20 +605,50 @@ void main() { 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; test('consume purchase async success', () async { final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); stubPlatform.addResponse( - name: consumeMethodName, - value: BillingResponseConverter().toJson(expectedCode)); - - final BillingResponse responseCode = await GooglePlayConnection.instance - .consumePurchase(PurchaseDetails.fromPurchase(dummyPurchase)); - - expect(responseCode, equals(expectedCode)); + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final BillingResultWrapper billingResultWrapper = + await GooglePlayConnection.instance + .consumePurchase(PurchaseDetails.fromPurchase(dummyPurchase)); + + expect(billingResultWrapper, equals(expectedBillingResult)); }); }); group('complete purchase', () { - test('calling complete purchase on android should throw', () async { - expect(() => connection.completePurchase(null), throwsUnsupportedError); + const String completeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('complete purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: completeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + PurchaseDetails purchaseDetails = + PurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + Completer completer = Completer(); + purchaseDetails.status = PurchaseStatus.purchased; + if (purchaseDetails.pendingCompletePurchase) { + final BillingResultWrapper billingResultWrapper = + await GooglePlayConnection.instance.completePurchase( + purchaseDetails, + developerPayload: 'dummy payload'); + print('pending ${billingResultWrapper.responseCode}'); + print('expectedBillingResult ${expectedBillingResult.responseCode}'); + print('pending ${billingResultWrapper.debugMessage}'); + print('expectedBillingResult ${expectedBillingResult.debugMessage}'); + expect(billingResultWrapper, equals(expectedBillingResult)); + completer.complete(billingResultWrapper); + } + expect(await completer.future, equals(expectedBillingResult)); }); }); }