diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md index 5769150bf7fb..2c7f2d085ce7 100644 --- a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.5.0 + +* Updates Google Play Billing Library from 7.1.1 to 8.0.0. +* **BREAKING CHANGES**: + * Removes `queryPurchaseHistory` and its wrapper `queryPurchaseHistoryAsync`. Use `queryPurchases` instead. +* Adds support for `subResponseCode` in `BillingResultWrapper`. +* Adds support for `oneTimePurchaseOfferDetailsList` in `ProductDetailsWrapper`. +* Adds support for `unfetchedProductList` in `ProductDetailsResponseWrapper` to handle product IDs that could not be fetched. + ## 0.4.0+8 * Bumps com.android.tools.build:gradle from 8.12.1 to 8.13.1. diff --git a/packages/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md index be9f0f507259..45286f90b573 100644 --- a/packages/in_app_purchase/in_app_purchase_android/README.md +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -19,8 +19,9 @@ Using the Alternative billing only feature requires Google Play app configuratio [Google Play documentation for Alternative billing](https://developer.android.com/google/play/billing/alternative) -## Migrating to 0.3.0 -To migrate to version 0.3.0 from 0.2.x, have a look at the [migration guide](migration_guide.md). +## Migrating to 0.5.0 +To migrate to version 0.5.0 from 0.4.x or 0.3.0 from 0.2.x, have a look at the +[migration guide](migration_guide.md). [1]: https://pub.dev/packages/in_app_purchase [2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index d74db0e7eba0..198af0f23499 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -60,7 +60,7 @@ android { dependencies { implementation("androidx.annotation:annotation:1.9.1") - implementation("com.android.billingclient:billing:7.1.1") + implementation("com.android.billingclient:billing:8.0.0") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250517") testImplementation("org.mockito:mockito-core:5.21.0") diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Messages.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Messages.java index fa1135fed036..cbd5a669faf8 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Messages.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Messages.java @@ -402,6 +402,19 @@ public void setDebugMessage(@NonNull String setterArg) { this.debugMessage = setterArg; } + private @NonNull Long subResponseCode; + + public @NonNull Long getSubResponseCode() { + return subResponseCode; + } + + public void setSubResponseCode(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"subResponseCode\" is null."); + } + this.subResponseCode = setterArg; + } + /** Constructor is non-public to enforce null safety; use Builder. */ PlatformBillingResult() {} @@ -414,12 +427,14 @@ public boolean equals(Object o) { return false; } PlatformBillingResult that = (PlatformBillingResult) o; - return responseCode.equals(that.responseCode) && debugMessage.equals(that.debugMessage); + return responseCode.equals(that.responseCode) + && debugMessage.equals(that.debugMessage) + && subResponseCode.equals(that.subResponseCode); } @Override public int hashCode() { - return Objects.hash(responseCode, debugMessage); + return Objects.hash(responseCode, debugMessage, subResponseCode); } public static final class Builder { @@ -440,19 +455,29 @@ public static final class Builder { return this; } + private @Nullable Long subResponseCode; + + @CanIgnoreReturnValue + public @NonNull Builder setSubResponseCode(@NonNull Long setterArg) { + this.subResponseCode = setterArg; + return this; + } + public @NonNull PlatformBillingResult build() { PlatformBillingResult pigeonReturn = new PlatformBillingResult(); pigeonReturn.setResponseCode(responseCode); pigeonReturn.setDebugMessage(debugMessage); + pigeonReturn.setSubResponseCode(subResponseCode); return pigeonReturn; } } @NonNull ArrayList toList() { - ArrayList toListResult = new ArrayList<>(2); + ArrayList toListResult = new ArrayList<>(3); toListResult.add(responseCode); toListResult.add(debugMessage); + toListResult.add(subResponseCode); return toListResult; } @@ -462,6 +487,8 @@ ArrayList toList() { pigeonResult.setResponseCode((PlatformBillingResponse) responseCode); Object debugMessage = pigeonVar_list.get(1); pigeonResult.setDebugMessage((String) debugMessage); + Object subResponseCode = pigeonVar_list.get(2); + pigeonResult.setSubResponseCode((Long) subResponseCode); return pigeonResult; } } @@ -673,6 +700,18 @@ public void setOneTimePurchaseOfferDetails( this.oneTimePurchaseOfferDetails = setterArg; } + private @Nullable List oneTimePurchaseOfferDetailsList; + + public @Nullable List + getOneTimePurchaseOfferDetailsList() { + return oneTimePurchaseOfferDetailsList; + } + + public void setOneTimePurchaseOfferDetailsList( + @Nullable List setterArg) { + this.oneTimePurchaseOfferDetailsList = setterArg; + } + private @Nullable List subscriptionOfferDetails; public @Nullable List getSubscriptionOfferDetails() { @@ -702,6 +741,7 @@ public boolean equals(Object o) { && productType.equals(that.productType) && title.equals(that.title) && Objects.equals(oneTimePurchaseOfferDetails, that.oneTimePurchaseOfferDetails) + && Objects.equals(oneTimePurchaseOfferDetailsList, that.oneTimePurchaseOfferDetailsList) && Objects.equals(subscriptionOfferDetails, that.subscriptionOfferDetails); } @@ -714,6 +754,7 @@ public int hashCode() { productType, title, oneTimePurchaseOfferDetails, + oneTimePurchaseOfferDetailsList, subscriptionOfferDetails); } @@ -768,6 +809,15 @@ public static final class Builder { return this; } + private @Nullable List oneTimePurchaseOfferDetailsList; + + @CanIgnoreReturnValue + public @NonNull Builder setOneTimePurchaseOfferDetailsList( + @Nullable List setterArg) { + this.oneTimePurchaseOfferDetailsList = setterArg; + return this; + } + private @Nullable List subscriptionOfferDetails; @CanIgnoreReturnValue @@ -785,6 +835,7 @@ public static final class Builder { pigeonReturn.setProductType(productType); pigeonReturn.setTitle(title); pigeonReturn.setOneTimePurchaseOfferDetails(oneTimePurchaseOfferDetails); + pigeonReturn.setOneTimePurchaseOfferDetailsList(oneTimePurchaseOfferDetailsList); pigeonReturn.setSubscriptionOfferDetails(subscriptionOfferDetails); return pigeonReturn; } @@ -792,13 +843,14 @@ public static final class Builder { @NonNull ArrayList toList() { - ArrayList toListResult = new ArrayList<>(7); + ArrayList toListResult = new ArrayList<>(8); toListResult.add(description); toListResult.add(name); toListResult.add(productId); toListResult.add(productType); toListResult.add(title); toListResult.add(oneTimePurchaseOfferDetails); + toListResult.add(oneTimePurchaseOfferDetailsList); toListResult.add(subscriptionOfferDetails); return toListResult; } @@ -818,7 +870,10 @@ ArrayList toList() { Object oneTimePurchaseOfferDetails = pigeonVar_list.get(5); pigeonResult.setOneTimePurchaseOfferDetails( (PlatformOneTimePurchaseOfferDetails) oneTimePurchaseOfferDetails); - Object subscriptionOfferDetails = pigeonVar_list.get(6); + Object oneTimePurchaseOfferDetailsList = pigeonVar_list.get(6); + pigeonResult.setOneTimePurchaseOfferDetailsList( + (List) oneTimePurchaseOfferDetailsList); + Object subscriptionOfferDetails = pigeonVar_list.get(7); pigeonResult.setSubscriptionOfferDetails( (List) subscriptionOfferDetails); return pigeonResult; @@ -858,6 +913,19 @@ public void setProductDetails(@NonNull List setterArg) { this.productDetails = setterArg; } + private @NonNull List unfetchedProductList; + + public @NonNull List getUnfetchedProductList() { + return unfetchedProductList; + } + + public void setUnfetchedProductList(@NonNull List setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"unfetchedProductList\" is null."); + } + this.unfetchedProductList = setterArg; + } + /** Constructor is non-public to enforce null safety; use Builder. */ PlatformProductDetailsResponse() {} @@ -870,12 +938,14 @@ public boolean equals(Object o) { return false; } PlatformProductDetailsResponse that = (PlatformProductDetailsResponse) o; - return billingResult.equals(that.billingResult) && productDetails.equals(that.productDetails); + return billingResult.equals(that.billingResult) + && productDetails.equals(that.productDetails) + && unfetchedProductList.equals(that.unfetchedProductList); } @Override public int hashCode() { - return Objects.hash(billingResult, productDetails); + return Objects.hash(billingResult, productDetails, unfetchedProductList); } public static final class Builder { @@ -896,19 +966,30 @@ public static final class Builder { return this; } + private @Nullable List unfetchedProductList; + + @CanIgnoreReturnValue + public @NonNull Builder setUnfetchedProductList( + @NonNull List setterArg) { + this.unfetchedProductList = setterArg; + return this; + } + public @NonNull PlatformProductDetailsResponse build() { PlatformProductDetailsResponse pigeonReturn = new PlatformProductDetailsResponse(); pigeonReturn.setBillingResult(billingResult); pigeonReturn.setProductDetails(productDetails); + pigeonReturn.setUnfetchedProductList(unfetchedProductList); return pigeonReturn; } } @NonNull ArrayList toList() { - ArrayList toListResult = new ArrayList<>(2); + ArrayList toListResult = new ArrayList<>(3); toListResult.add(billingResult); toListResult.add(productDetails); + toListResult.add(unfetchedProductList); return toListResult; } @@ -919,6 +1000,8 @@ ArrayList toList() { pigeonResult.setBillingResult((PlatformBillingResult) billingResult); Object productDetails = pigeonVar_list.get(1); pigeonResult.setProductDetails((List) productDetails); + Object unfetchedProductList = pigeonVar_list.get(2); + pigeonResult.setUnfetchedProductList((List) unfetchedProductList); return pigeonResult; } } @@ -3106,6 +3189,78 @@ ArrayList toList() { } } + /** + * Pigeon version of Java + * [UnfetchedProduct](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). + * + *

Generated class from Pigeon that represents data sent in messages. + */ + public static final class PlatformUnfetchedProduct { + private @NonNull String productId; + + public @NonNull String getProductId() { + return productId; + } + + public void setProductId(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"productId\" is null."); + } + this.productId = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + PlatformUnfetchedProduct() {} + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PlatformUnfetchedProduct that = (PlatformUnfetchedProduct) o; + return productId.equals(that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(productId); + } + + public static final class Builder { + + private @Nullable String productId; + + @CanIgnoreReturnValue + public @NonNull Builder setProductId(@NonNull String setterArg) { + this.productId = setterArg; + return this; + } + + public @NonNull PlatformUnfetchedProduct build() { + PlatformUnfetchedProduct pigeonReturn = new PlatformUnfetchedProduct(); + pigeonReturn.setProductId(productId); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(1); + toListResult.add(productId); + return toListResult; + } + + static @NonNull PlatformUnfetchedProduct fromList(@NonNull ArrayList pigeonVar_list) { + PlatformUnfetchedProduct pigeonResult = new PlatformUnfetchedProduct(); + Object productId = pigeonVar_list.get(0); + pigeonResult.setProductId((String) productId); + return pigeonResult; + } + } + private static class PigeonCodec extends StandardMessageCodec { public static final PigeonCodec INSTANCE = new PigeonCodec(); @@ -3201,6 +3356,8 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { return PlatformInstallmentPlanDetails.fromList((ArrayList) readValue(buffer)); case (byte) 155: return PlatformPendingPurchasesParams.fromList((ArrayList) readValue(buffer)); + case (byte) 156: + return PlatformUnfetchedProduct.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); } @@ -3290,6 +3447,9 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof PlatformPendingPurchasesParams) { stream.write(155); writeValue(stream, ((PlatformPendingPurchasesParams) value).toList()); + } else if (value instanceof PlatformUnfetchedProduct) { + stream.write(156); + writeValue(stream, ((PlatformUnfetchedProduct) value).toList()); } else { super.writeValue(stream, value); } @@ -3353,13 +3513,6 @@ void acknowledgePurchase( void queryPurchasesAsync( @NonNull PlatformProductType productType, @NonNull Result result); - /** - * Wraps BillingClient#queryPurchaseHistoryAsync(QueryPurchaseHistoryParams, - * PurchaseHistoryResponseListener). - */ - void queryPurchaseHistoryAsync( - @NonNull PlatformProductType productType, - @NonNull Result result); /** * Wraps BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, * ProductDetailsResponseListener). @@ -3630,38 +3783,6 @@ public void error(Throwable error) { channel.setMessageHandler(null); } } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.in_app_purchase_android.InAppPurchaseApi.queryPurchaseHistoryAsync" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - ArrayList args = (ArrayList) message; - PlatformProductType productTypeArg = (PlatformProductType) args.get(0); - Result resultCallback = - new Result() { - public void success(PlatformPurchaseHistoryResponse result) { - wrapped.add(0, result); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.queryPurchaseHistoryAsync(productTypeArg, resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } { BasicMessageChannel channel = new BasicMessageChannel<>( diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java index d496fb57b323..42f2f7d604e1 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -8,8 +8,8 @@ import static io.flutter.plugins.inapppurchase.Translator.fromBillingConfig; import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; import static io.flutter.plugins.inapppurchase.Translator.fromProductDetailsList; -import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; +import static io.flutter.plugins.inapppurchase.Translator.fromUnfetchedProductList; import static io.flutter.plugins.inapppurchase.Translator.toBillingClientFeature; import static io.flutter.plugins.inapppurchase.Translator.toProductList; import static io.flutter.plugins.inapppurchase.Translator.toProductTypeString; @@ -33,7 +33,6 @@ import com.android.billingclient.api.GetBillingConfigParams; import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.QueryProductDetailsParams; -import com.android.billingclient.api.QueryPurchaseHistoryParams; import com.android.billingclient.api.QueryPurchasesParams; import io.flutter.plugins.inapppurchase.Messages.FlutterError; import io.flutter.plugins.inapppurchase.Messages.InAppPurchaseApi; @@ -44,7 +43,6 @@ import io.flutter.plugins.inapppurchase.Messages.PlatformBillingResult; import io.flutter.plugins.inapppurchase.Messages.PlatformProductDetailsResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformProductType; -import io.flutter.plugins.inapppurchase.Messages.PlatformPurchaseHistoryResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformPurchasesResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformQueryProduct; import io.flutter.plugins.inapppurchase.Messages.PlatformReplacementMode; @@ -228,12 +226,15 @@ public void queryProductDetailsAsync( QueryProductDetailsParams.newBuilder().setProductList(toProductList(products)).build(); billingClient.queryProductDetailsAsync( params, - (billingResult, productDetailsList) -> { - updateCachedProducts(productDetailsList); + (billingResult, productDetailsResult) -> { + updateCachedProducts(productDetailsResult.getProductDetailsList()); final PlatformProductDetailsResponse.Builder responseBuilder = new PlatformProductDetailsResponse.Builder() .setBillingResult(fromBillingResult(billingResult)) - .setProductDetails(fromProductDetailsList(productDetailsList)); + .setProductDetails( + fromProductDetailsList(productDetailsResult.getProductDetailsList())) + .setUnfetchedProductList( + fromUnfetchedProductList(productDetailsResult.getUnfetchedProductList())); result.success(responseBuilder.build()); }); } catch (RuntimeException e) { @@ -396,33 +397,6 @@ public void queryPurchasesAsync( } } - @Override - @Deprecated - public void queryPurchaseHistoryAsync( - @NonNull PlatformProductType productType, - @NonNull Result result) { - if (billingClient == null) { - result.error(getNullBillingClientError()); - return; - } - - try { - billingClient.queryPurchaseHistoryAsync( - QueryPurchaseHistoryParams.newBuilder() - .setProductType(toProductTypeString(productType)) - .build(), - (billingResult, purchasesList) -> { - PlatformPurchaseHistoryResponse.Builder builder = - new PlatformPurchaseHistoryResponse.Builder() - .setBillingResult(fromBillingResult(billingResult)) - .setPurchases(fromPurchaseHistoryRecordList(purchasesList)); - result.success(builder.build()); - }); - } catch (RuntimeException e) { - result.error(new FlutterError("error", e.getMessage(), Log.getStackTraceString(e))); - } - } - @Override public void startConnection( @NonNull Long handle, diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java index a982964764dc..2d1a9f571316 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -17,6 +17,7 @@ import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; import com.android.billingclient.api.QueryProductDetailsParams; +import com.android.billingclient.api.UnfetchedProduct; import com.android.billingclient.api.UserChoiceDetails; import io.flutter.plugins.inapppurchase.Messages.FlutterError; import io.flutter.plugins.inapppurchase.Messages.PlatformAccountIdentifiers; @@ -37,6 +38,7 @@ import io.flutter.plugins.inapppurchase.Messages.PlatformRecurrenceMode; import io.flutter.plugins.inapppurchase.Messages.PlatformReplacementMode; import io.flutter.plugins.inapppurchase.Messages.PlatformSubscriptionOfferDetails; +import io.flutter.plugins.inapppurchase.Messages.PlatformUnfetchedProduct; import io.flutter.plugins.inapppurchase.Messages.PlatformUserChoiceDetails; import io.flutter.plugins.inapppurchase.Messages.PlatformUserChoiceProduct; import java.util.ArrayList; @@ -59,6 +61,8 @@ .setName(detail.getName()) .setOneTimePurchaseOfferDetails( fromOneTimePurchaseOfferDetails(detail.getOneTimePurchaseOfferDetails())) + .setOneTimePurchaseOfferDetailsList( + fromOneTimePurchaseOfferDetailsList(detail.getOneTimePurchaseOfferDetailsList())) .setSubscriptionOfferDetails( fromSubscriptionOfferDetailsList(detail.getSubscriptionOfferDetails())) .build(); @@ -116,6 +120,21 @@ static PlatformProductType toPlatformProductType(@NonNull String typeString) { return output; } + static @Nullable List fromOneTimePurchaseOfferDetailsList( + @Nullable List oneTimePurchaseOfferDetailsList) { + if (oneTimePurchaseOfferDetailsList == null) { + return null; + } + + ArrayList serialized = new ArrayList<>(); + for (ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails : + oneTimePurchaseOfferDetailsList) { + serialized.add(fromOneTimePurchaseOfferDetails(oneTimePurchaseOfferDetails)); + } + + return serialized; + } + static @Nullable PlatformOneTimePurchaseOfferDetails fromOneTimePurchaseOfferDetails( @Nullable ProductDetails.OneTimePurchaseOfferDetails oneTimePurchaseOfferDetails) { if (oneTimePurchaseOfferDetails == null) { @@ -302,6 +321,26 @@ static PlatformPurchaseState toPlatformPurchaseState(int state) { return new PlatformBillingResult.Builder() .setResponseCode(fromBillingResponseCode(billingResult.getResponseCode())) .setDebugMessage(billingResult.getDebugMessage()) + .setSubResponseCode((long) billingResult.getOnPurchasesUpdatedSubResponseCode()) + .build(); + } + + static @NonNull List fromUnfetchedProductList( + @Nullable List unfetchedProductList) { + if (unfetchedProductList == null) { + return Collections.emptyList(); + } + List serialized = new ArrayList<>(); + for (UnfetchedProduct unfetchedProduct : unfetchedProductList) { + serialized.add(fromUnfetchedProduct(unfetchedProduct)); + } + return serialized; + } + + static @NonNull PlatformUnfetchedProduct fromUnfetchedProduct( + @NonNull UnfetchedProduct unfetchedProduct) { + return new PlatformUnfetchedProduct.Builder() + .setProductId(unfetchedProduct.getProductId()) .build(); } diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java index d4cfeda77d0d..c4e84209526d 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -49,10 +49,9 @@ import com.android.billingclient.api.ProductDetailsResponseListener; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchaseHistoryRecord; -import com.android.billingclient.api.PurchaseHistoryResponseListener; import com.android.billingclient.api.PurchasesResponseListener; import com.android.billingclient.api.QueryProductDetailsParams; -import com.android.billingclient.api.QueryPurchaseHistoryParams; +import com.android.billingclient.api.QueryProductDetailsResult; import com.android.billingclient.api.QueryPurchasesParams; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugins.inapppurchase.Messages.FlutterError; @@ -65,7 +64,6 @@ import io.flutter.plugins.inapppurchase.Messages.PlatformBillingResult; import io.flutter.plugins.inapppurchase.Messages.PlatformProductDetailsResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformProductType; -import io.flutter.plugins.inapppurchase.Messages.PlatformPurchaseHistoryResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformPurchasesResponse; import io.flutter.plugins.inapppurchase.Messages.PlatformQueryProduct; import io.flutter.plugins.inapppurchase.Messages.PlatformReplacementMode; @@ -99,7 +97,7 @@ public class MethodCallHandlerTest { @Spy Messages.Result platformBillingConfigResult; @Spy Messages.Result platformBillingResult; @Spy Messages.Result platformProductDetailsResult; - @Spy Messages.Result platformPurchaseHistoryResult; + @Spy Messages.Result platformPurchasesResult; @Mock Activity activity; @@ -577,13 +575,20 @@ public void queryProductDetailsAsync() { // Assert that we handed result BillingClient's response List productDetailsResponse = singletonList(buildProductDetails("foo")); BillingResult billingResult = buildBillingResult(); - listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); + + QueryProductDetailsResult mockProductDetailsResult = mock(QueryProductDetailsResult.class); + when(mockProductDetailsResult.getProductDetailsList()).thenReturn(productDetailsResponse); + when(mockProductDetailsResult.getUnfetchedProductList()) + .thenReturn(java.util.Collections.emptyList()); + + listenerCaptor.getValue().onProductDetailsResponse(billingResult, mockProductDetailsResult); ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(PlatformProductDetailsResponse.class); verify(platformProductDetailsResult).success(resultCaptor.capture()); PlatformProductDetailsResponse resultData = resultCaptor.getValue(); assertResultsMatch(resultData.getBillingResult(), billingResult); assertDetailListsMatch(productDetailsResponse, resultData.getProductDetails()); + assertTrue(resultData.getUnfetchedProductList().isEmpty()); } @Test @@ -976,51 +981,6 @@ public void queryPurchases_returns_success() { assertTrue(purchasesResponse.getPurchases().isEmpty()); } - @Test - @SuppressWarnings(value = "deprecation") - public void queryPurchaseHistoryAsync() { - // Set up an established billing client and all our mocked responses - establishConnectedBillingClient(); - BillingResult billingResult = buildBillingResult(); - final String purchaseToken = "foo"; - List purchasesList = - singletonList(buildPurchaseHistoryRecord(purchaseToken)); - ArgumentCaptor listenerCaptor = - ArgumentCaptor.forClass(PurchaseHistoryResponseListener.class); - - methodChannelHandler.queryPurchaseHistoryAsync( - PlatformProductType.INAPP, platformPurchaseHistoryResult); - - // Verify we pass the data to result - verify(mockBillingClient) - .queryPurchaseHistoryAsync(any(QueryPurchaseHistoryParams.class), listenerCaptor.capture()); - listenerCaptor.getValue().onPurchaseHistoryResponse(billingResult, purchasesList); - ArgumentCaptor resultCaptor = - ArgumentCaptor.forClass(PlatformPurchaseHistoryResponse.class); - verify(platformPurchaseHistoryResult).success(resultCaptor.capture()); - PlatformPurchaseHistoryResponse result = resultCaptor.getValue(); - assertResultsMatch(result.getBillingResult(), billingResult); - assertEquals(1, result.getPurchases().size()); - assertEquals(purchaseToken, result.getPurchases().get(0).getPurchaseToken()); - } - - @Test - @SuppressWarnings(value = "deprecation") - public void queryPurchaseHistoryAsync_clientDisconnected() { - methodChannelHandler.endConnection(); - - methodChannelHandler.queryPurchaseHistoryAsync( - PlatformProductType.INAPP, platformPurchaseHistoryResult); - - // Assert that the async call returns an error result. - verify(platformPurchaseHistoryResult, never()).success(any()); - ArgumentCaptor errorCaptor = ArgumentCaptor.forClass(FlutterError.class); - verify(platformPurchaseHistoryResult, times(1)).error(errorCaptor.capture()); - assertEquals("UNAVAILABLE", errorCaptor.getValue().code); - assertTrue( - Objects.requireNonNull(errorCaptor.getValue().getMessage()).contains("BillingClient")); - } - @Test public void onPurchasesUpdatedListener() { PluginPurchaseListener listener = new PluginPurchaseListener(mockCallbackApi); @@ -1177,7 +1137,11 @@ private void queryForProducts(List productIdList) { productIdList.stream().map(this::buildProductDetails).collect(toList()); BillingResult billingResult = buildBillingResult(); - listenerCaptor.getValue().onProductDetailsResponse(billingResult, productDetailsResponse); + QueryProductDetailsResult mockProductDetailsResult = mock(QueryProductDetailsResult.class); + when(mockProductDetailsResult.getProductDetailsList()).thenReturn(productDetailsResponse); + when(mockProductDetailsResult.getUnfetchedProductList()) + .thenReturn(java.util.Collections.emptyList()); + listenerCaptor.getValue().onProductDetailsResponse(billingResult, mockProductDetailsResult); } private List buildProductList( diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java index b8c7fb4e8ccb..5b327a35dbcc 100644 --- a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -177,6 +177,9 @@ public void fromBillingResult_debugMessageNull() { assertEquals(Messages.PlatformBillingResponse.OK, platformResult.getResponseCode()); assertEquals(platformResult.getDebugMessage(), newBillingResult.getDebugMessage()); + assertEquals( + (long) newBillingResult.getOnPurchasesUpdatedSubResponseCode(), + (long) platformResult.getSubResponseCode()); } @Test @@ -206,6 +209,21 @@ private void assertSerialized( assertSerialized(expectedOneTimePurchaseOfferDetails, oneTimePurchaseOfferDetails); } + List expectedOfferList = + expected.getOneTimePurchaseOfferDetailsList(); + List serializedOfferList = + serialized.getOneTimePurchaseOfferDetailsList(); + + if (expectedOfferList == null) { + assertNull(serializedOfferList); + } else { + assertNotNull(serializedOfferList); + assertEquals(expectedOfferList.size(), serializedOfferList.size()); + for (int i = 0; i < expectedOfferList.size(); i++) { + assertSerialized(expectedOfferList.get(i), serializedOfferList.get(i)); + } + } + List expectedSubscriptionOfferDetailsList = expected.getSubscriptionOfferDetails(); List subscriptionOfferDetailsList = diff --git a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart index b06a166dce11..e6be023edddb 100644 --- a/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/example/integration_test/in_app_purchase_test.dart @@ -111,18 +111,6 @@ void main() { } }); - testWidgets('BillingClient.queryPurchaseHistory', ( - WidgetTester tester, - ) async { - try { - // Intentional use of a deprecated method to make sure it still works. - // ignore: deprecated_member_use - await billingClient.queryPurchaseHistory(ProductType.inapp); - } on MissingPluginException { - fail('Method channel is not setup correctly'); - } - }); - testWidgets('BillingClient.queryPurchases', (WidgetTester tester) async { try { await billingClient.queryPurchases(ProductType.inapp); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart index b3df0cf619b9..c49e67e9496a 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -249,29 +249,6 @@ class BillingClient { ); } - /// Fetches purchase history for the given [ProductType]. - /// - /// Unlike [queryPurchases], this makes a network request via Play and returns - /// the most recent purchase for each [ProductDetailsWrapper] of the given - /// [ProductType] even if the item is no longer owned. - /// - /// All purchase information should also be verified manually, with your - /// server if at all possible. See ["Verify a - /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). - /// - /// This wraps - /// [`BillingClient#queryPurchaseHistoryAsync(QueryPurchaseHistoryParams, PurchaseHistoryResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#queryPurchaseHistoryAsync(com.android.billingclient.api.QueryPurchaseHistoryParams,%20com.android.billingclient.api.PurchaseHistoryResponseListener)). - @Deprecated('Use queryPurchases') - Future queryPurchaseHistory( - ProductType productType, - ) async { - return purchaseHistoryResultFromPlatform( - await _hostApi.queryPurchaseHistoryAsync( - platformProductTypeFromWrapper(productType), - ), - ); - } - /// 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. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart index 8f971b2cb405..22b83f37fe80 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_response_wrapper.dart @@ -17,12 +17,26 @@ const String kInvalidBillingResultErrorMessage = @immutable class BillingResultWrapper implements HasBillingResponse { /// Constructs the object with [responseCode] and [debugMessage]. - const BillingResultWrapper({required this.responseCode, this.debugMessage}); + const BillingResultWrapper({ + required this.responseCode, + this.subResponseCode = 0, + this.debugMessage, + }); /// Response code returned in the Play Billing API calls. @override final BillingResponse responseCode; + /// Sub-response code returned in the Play Billing API calls. + /// + /// Defaults to 0 which is returned when no other sub-response code is applicable. + /// + /// Possible values: + /// * `0`: `NO_APPLICABLE_SUB_RESPONSE_CODE` + /// * `1`: `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` + /// * `2`: `USER_INELIGIBLE` + final int subResponseCode; + /// Debug message returned in the Play Billing API calls. /// /// Defaults to `null`. @@ -39,9 +53,10 @@ class BillingResultWrapper implements HasBillingResponse { return other is BillingResultWrapper && other.responseCode == responseCode && + other.subResponseCode == subResponseCode && other.debugMessage == debugMessage; } @override - int get hashCode => Object.hash(responseCode, debugMessage); + int get hashCode => Object.hash(responseCode, subResponseCode, debugMessage); } diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart index cf700162b237..ce8241113121 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/product_details_wrapper.dart @@ -17,6 +17,7 @@ class ProductDetailsWrapper { required this.description, required this.name, this.oneTimePurchaseOfferDetails, + this.oneTimePurchaseOfferDetailsList, required this.productId, required this.productType, this.subscriptionOfferDetails, @@ -38,6 +39,13 @@ class ProductDetailsWrapper { /// null for [ProductType.subs]. final OneTimePurchaseOfferDetailsWrapper? oneTimePurchaseOfferDetails; + /// The list of offer details for a one-time purchase product. + /// + /// [oneTimePurchaseOfferDetailsList] is only set for [ProductType.inapp]. + /// Returns null for [ProductType.subs]. + final List? + oneTimePurchaseOfferDetailsList; + /// The product's id. final String productId; @@ -66,6 +74,10 @@ class ProductDetailsWrapper { other.description == description && other.name == name && other.oneTimePurchaseOfferDetails == oneTimePurchaseOfferDetails && + listEquals( + other.oneTimePurchaseOfferDetailsList, + oneTimePurchaseOfferDetailsList, + ) && other.productId == productId && other.productType == productType && listEquals(other.subscriptionOfferDetails, subscriptionOfferDetails) && @@ -78,6 +90,7 @@ class ProductDetailsWrapper { description.hashCode, name.hashCode, oneTimePurchaseOfferDetails.hashCode, + oneTimePurchaseOfferDetailsList.hashCode, productId.hashCode, productType.hashCode, subscriptionOfferDetails.hashCode, @@ -95,6 +108,7 @@ class ProductDetailsResponseWrapper implements HasBillingResponse { const ProductDetailsResponseWrapper({ required this.billingResult, required this.productDetailsList, + this.unfetchedProductList = const [], }); /// The final result of the [BillingClient.queryProductDetails] call. @@ -103,6 +117,9 @@ class ProductDetailsResponseWrapper implements HasBillingResponse { /// A list of [ProductDetailsWrapper] matching the query to [BillingClient.queryProductDetails]. final List productDetailsList; + /// A list of [UnfetchedProductWrapper] that could not be fetched by [BillingClient.queryProductDetails]. + final List unfetchedProductList; + @override BillingResponse get responseCode => billingResult.responseCode; @@ -114,11 +131,37 @@ class ProductDetailsResponseWrapper implements HasBillingResponse { return other is ProductDetailsResponseWrapper && other.billingResult == billingResult && - other.productDetailsList == productDetailsList; + listEquals(other.productDetailsList, productDetailsList) && + listEquals(other.unfetchedProductList, unfetchedProductList); + } + + @override + int get hashCode => + Object.hash(billingResult, productDetailsList, unfetchedProductList); +} + +/// Dart wrapper around [`com.android.billingclient.api.QueryProductDetailsParams.Product`](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). +/// +/// Contains the details of a product that could not be fetched by the Google Play Billing Library. +@immutable +class UnfetchedProductWrapper { + /// Creates an [UnfetchedProductWrapper]. + const UnfetchedProductWrapper({required this.productId}); + + /// The product ID that could not be fetched. + final String productId; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + + return other is UnfetchedProductWrapper && other.productId == productId; } @override - int get hashCode => Object.hash(billingResult, productDetailsList); + int get hashCode => productId.hashCode; } /// Recurrence mode of the pricing phase. diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/messages.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/messages.g.dart index cbcdb28231df..9aff97c98fdb 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/messages.g.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/messages.g.dart @@ -202,14 +202,17 @@ class PlatformBillingResult { PlatformBillingResult({ required this.responseCode, required this.debugMessage, + this.subResponseCode = 0, }); PlatformBillingResponse responseCode; String debugMessage; + int subResponseCode; + List _toList() { - return [responseCode, debugMessage]; + return [responseCode, debugMessage, subResponseCode]; } Object encode() { @@ -221,6 +224,7 @@ class PlatformBillingResult { return PlatformBillingResult( responseCode: result[0]! as PlatformBillingResponse, debugMessage: result[1]! as String, + subResponseCode: result[2]! as int, ); } @@ -299,6 +303,7 @@ class PlatformProductDetails { required this.productType, required this.title, this.oneTimePurchaseOfferDetails, + this.oneTimePurchaseOfferDetailsList, this.subscriptionOfferDetails, }); @@ -314,6 +319,8 @@ class PlatformProductDetails { PlatformOneTimePurchaseOfferDetails? oneTimePurchaseOfferDetails; + List? oneTimePurchaseOfferDetailsList; + List? subscriptionOfferDetails; List _toList() { @@ -324,6 +331,7 @@ class PlatformProductDetails { productType, title, oneTimePurchaseOfferDetails, + oneTimePurchaseOfferDetailsList, subscriptionOfferDetails, ]; } @@ -342,7 +350,9 @@ class PlatformProductDetails { title: result[4]! as String, oneTimePurchaseOfferDetails: result[5] as PlatformOneTimePurchaseOfferDetails?, - subscriptionOfferDetails: (result[6] as List?) + oneTimePurchaseOfferDetailsList: (result[6] as List?) + ?.cast(), + subscriptionOfferDetails: (result[7] as List?) ?.cast(), ); } @@ -370,14 +380,17 @@ class PlatformProductDetailsResponse { PlatformProductDetailsResponse({ required this.billingResult, required this.productDetails, + required this.unfetchedProductList, }); PlatformBillingResult billingResult; List productDetails; + List unfetchedProductList; + List _toList() { - return [billingResult, productDetails]; + return [billingResult, productDetails, unfetchedProductList]; } Object encode() { @@ -390,6 +403,8 @@ class PlatformProductDetailsResponse { billingResult: result[0]! as PlatformBillingResult, productDetails: (result[1] as List?)! .cast(), + unfetchedProductList: (result[2] as List?)! + .cast(), ); } @@ -1230,6 +1245,43 @@ class PlatformPendingPurchasesParams { int get hashCode => Object.hashAll(_toList()); } +/// Pigeon version of Java [UnfetchedProduct](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). +class PlatformUnfetchedProduct { + PlatformUnfetchedProduct({required this.productId}); + + String productId; + + List _toList() { + return [productId]; + } + + Object encode() { + return _toList(); + } + + static PlatformUnfetchedProduct decode(Object result) { + result as List; + return PlatformUnfetchedProduct(productId: result[0]! as String); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlatformUnfetchedProduct || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -1319,6 +1371,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is PlatformPendingPurchasesParams) { buffer.putUint8(155); writeValue(buffer, value.encode()); + } else if (value is PlatformUnfetchedProduct) { + buffer.putUint8(156); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -1392,6 +1447,8 @@ class _PigeonCodec extends StandardMessageCodec { return PlatformInstallmentPlanDetails.decode(readValue(buffer)!); case 155: return PlatformPendingPurchasesParams.decode(readValue(buffer)!); + case 156: + return PlatformUnfetchedProduct.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -1678,41 +1735,6 @@ class InAppPurchaseApi { } } - /// Wraps BillingClient#queryPurchaseHistoryAsync(QueryPurchaseHistoryParams, PurchaseHistoryResponseListener). - Future queryPurchaseHistoryAsync( - PlatformProductType productType, - ) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.in_app_purchase_android.InAppPurchaseApi.queryPurchaseHistoryAsync$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [productType], - ); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as PlatformPurchaseHistoryResponse?)!; - } - } - /// Wraps BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener). Future queryProductDetailsAsync( List products, diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/pigeon_converters.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/pigeon_converters.dart index c3586df9c6a7..64aa8fac4102 100644 --- a/packages/in_app_purchase/in_app_purchase_android/lib/src/pigeon_converters.dart +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/pigeon_converters.dart @@ -25,6 +25,7 @@ PlatformBillingChoiceMode platformBillingChoiceMode(BillingChoiceMode mode) { BillingResultWrapper resultWrapperFromPlatform(PlatformBillingResult result) { return BillingResultWrapper( responseCode: billingResponseFromPlatform(result.responseCode), + subResponseCode: result.subResponseCode, debugMessage: result.debugMessage, ); } @@ -38,6 +39,9 @@ ProductDetailsResponseWrapper productDetailsResponseWrapperFromPlatform( productDetailsList: response.productDetails .map(productDetailsWrapperFromPlatform) .toList(), + unfetchedProductList: response.unfetchedProductList + .map(unfetchedProductWrapperFromPlatform) + .toList(), ); } @@ -54,12 +58,23 @@ ProductDetailsWrapper productDetailsWrapperFromPlatform( oneTimePurchaseOfferDetails: oneTimePurchaseOfferDetailsWrapperFromPlatform( product.oneTimePurchaseOfferDetails, ), + oneTimePurchaseOfferDetailsList: product.oneTimePurchaseOfferDetailsList + ?.map(oneTimePurchaseOfferDetailsWrapperFromPlatform) + .whereType() + .toList(), subscriptionOfferDetails: product.subscriptionOfferDetails ?.map(subscriptionOfferDetailsWrapperFromPlatform) .toList(), ); } +/// Creates a [UnfetchedProductWrapper] from the Pigeon equivalent. +UnfetchedProductWrapper unfetchedProductWrapperFromPlatform( + PlatformUnfetchedProduct product, +) { + return UnfetchedProductWrapper(productId: product.productId); +} + /// Creates a [OneTimePurchaseOfferDetailsWrapper] from the Pigeon equivalent. OneTimePurchaseOfferDetailsWrapper? oneTimePurchaseOfferDetailsWrapperFromPlatform( diff --git a/packages/in_app_purchase/in_app_purchase_android/migration_guide.md b/packages/in_app_purchase/in_app_purchase_android/migration_guide.md index 5d0ef0a7d917..ac6907474107 100644 --- a/packages/in_app_purchase/in_app_purchase_android/migration_guide.md +++ b/packages/in_app_purchase/in_app_purchase_android/migration_guide.md @@ -1,4 +1,28 @@ +# Migration Guide from 0.4.x to 0.5.0 + +Version 0.5.0 updates the Android embedding to use Google Play Billing Library 8.0.0. This update includes breaking changes unrelated to the Dart API surface, but specific methods in `BillingClientWrapper` have been removed to align with the native library. + +## Removal of `queryPurchaseHistory` + +The `queryPurchaseHistory` method in `BillingClientWrapper` has been removed because the underlying +native method `queryPurchaseHistoryAsync` was removed in Google Play Billing Library 7.0.0. + +Instead, use `queryPurchases` (which calls `queryPurchasesAsync` natively) to fetch active purchases. +This is now the recommended way to check for existing purchases. + +`queryPurchaseHistory` previously allowed checking for canceled, refunded, or voided purchases. +With its removal, this information is no longer available through the Billing Client. +The [Google Play Developer API](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get) +can be used to verify the state of past purchases. + +### Enhancements + +* `BillingResultWrapper` and `PlatformBillingResult` now include a `subResponseCode` field. +* `ProductDetailsResponseWrapper` and `PlatformProductDetailsResponse` now include an +`unfetchedProductList` to identify products that could not be retrieved. +* `ProductDetailsWrapper` now supports `oneTimePurchaseOfferDetailsList` for products with multiple buy options. + # Migration Guide from 0.2.x to 0.3.0 Starting November 2023, Android Billing Client V4 is no longer supported, diff --git a/packages/in_app_purchase/in_app_purchase_android/pigeons/messages.dart b/packages/in_app_purchase/in_app_purchase_android/pigeons/messages.dart index 90f987cc8dfc..b510d8e26422 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pigeons/messages.dart +++ b/packages/in_app_purchase/in_app_purchase_android/pigeons/messages.dart @@ -37,9 +37,11 @@ class PlatformBillingResult { PlatformBillingResult({ required this.responseCode, required this.debugMessage, + this.subResponseCode = 0, }); final PlatformBillingResponse responseCode; final String debugMessage; + final int subResponseCode; } /// Pigeon version of Java BillingClient.BillingResponseCode. @@ -81,6 +83,7 @@ class PlatformProductDetails { required this.productType, required this.title, required this.oneTimePurchaseOfferDetails, + required this.oneTimePurchaseOfferDetailsList, required this.subscriptionOfferDetails, }); @@ -90,6 +93,8 @@ class PlatformProductDetails { final PlatformProductType productType; final String title; final PlatformOneTimePurchaseOfferDetails? oneTimePurchaseOfferDetails; + final List? + oneTimePurchaseOfferDetailsList; final List? subscriptionOfferDetails; } @@ -99,10 +104,12 @@ class PlatformProductDetailsResponse { PlatformProductDetailsResponse({ required this.billingResult, required this.productDetails, + required this.unfetchedProductList, }); final PlatformBillingResult billingResult; final List productDetails; + final List unfetchedProductList; } /// Pigeon version of AlternativeBillingOnlyReportingDetailsWrapper, which @@ -344,6 +351,13 @@ class PlatformPendingPurchasesParams { final bool enablePrepaidPlans; } +/// Pigeon version of Java [UnfetchedProduct](https://developer.android.com/reference/com/android/billingclient/api/QueryProductDetailsParams.Product). +class PlatformUnfetchedProduct { + PlatformUnfetchedProduct({required this.productId}); + + final String productId; +} + /// Pigeon version of Java BillingClient.ProductType. enum PlatformProductType { inapp, subs } @@ -416,12 +430,6 @@ abstract class InAppPurchaseApi { PlatformProductType productType, ); - /// Wraps BillingClient#queryPurchaseHistoryAsync(QueryPurchaseHistoryParams, PurchaseHistoryResponseListener). - @async - PlatformPurchaseHistoryResponse queryPurchaseHistoryAsync( - PlatformProductType productType, - ); - /// Wraps BillingClient#queryProductDetailsAsync(QueryProductDetailsParams, ProductDetailsResponseListener). @async PlatformProductDetailsResponse queryProductDetailsAsync( diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml index 06a91eb3cfaa..2d68aec736e4 100644 --- a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -3,7 +3,7 @@ description: An implementation for the Android platform of the Flutter `in_app_p repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.0+8 +version: 0.5.0 environment: sdk: ^3.9.0 diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart index 2959ea704712..b9099a02179c 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -195,6 +195,7 @@ void main() { debugMessage: debugMessage, ), productDetails: [], + unfetchedProductList: [], ), ); @@ -228,6 +229,7 @@ void main() { productDetails: [ convertToPigeonProductDetails(dummyOneTimeProductDetails), ], + unfetchedProductList: [], ), ); @@ -248,6 +250,35 @@ void main() { expect(response.billingResult, equals(billingResult)); expect(response.productDetailsList, contains(dummyOneTimeProductDetails)); }); + + test('returns unfetchedProductList', () async { + const debugMessage = 'dummy message'; + when(mockApi.queryProductDetailsAsync(any)).thenAnswer( + (_) async => PlatformProductDetailsResponse( + billingResult: PlatformBillingResult( + responseCode: PlatformBillingResponse.ok, + debugMessage: debugMessage, + ), + productDetails: [], + unfetchedProductList: [ + PlatformUnfetchedProduct(productId: 'unfetched'), + ], + ), + ); + + final ProductDetailsResponseWrapper response = await billingClient + .queryProductDetails( + productList: [ + const ProductWrapper( + productId: 'unfetched', + productType: ProductType.inapp, + ), + ], + ); + + expect(response.unfetchedProductList, hasLength(1)); + expect(response.unfetchedProductList[0].productId, 'unfetched'); + }); }); group('launchBillingFlow', () { @@ -256,6 +287,7 @@ void main() { const BillingResponse responseCode = BillingResponse.ok; const expectedBillingResult = BillingResultWrapper( responseCode: responseCode, + subResponseCode: 123, debugMessage: debugMessage, ); when( @@ -525,32 +557,6 @@ void main() { }); }); - group('queryPurchaseHistory', () { - test('handles empty purchases', () async { - const BillingResponse expectedCode = BillingResponse.userCanceled; - const debugMessage = 'dummy message'; - const expectedBillingResult = BillingResultWrapper( - responseCode: expectedCode, - debugMessage: debugMessage, - ); - when(mockApi.queryPurchaseHistoryAsync(any)).thenAnswer( - (_) async => PlatformPurchaseHistoryResponse( - billingResult: PlatformBillingResult( - responseCode: PlatformBillingResponse.userCanceled, - debugMessage: debugMessage, - ), - purchases: [], - ), - ); - - final PurchasesHistoryResult response = await billingClient - .queryPurchaseHistory(ProductType.inapp); - - expect(response.billingResult, equals(expectedBillingResult)); - expect(response.purchaseHistoryRecordList, isEmpty); - }); - }); - group('consume purchases', () { test('consume purchase async success', () async { const token = 'dummy token'; @@ -684,6 +690,15 @@ void main() { expect(result, expected); }); }); + + test('UnfetchedProductWrapper equality', () { + const product1 = UnfetchedProductWrapper(productId: 'id'); + const product2 = UnfetchedProductWrapper(productId: 'id'); + const product3 = UnfetchedProductWrapper(productId: 'other'); + + expect(product1, product2); + expect(product1, isNot(product3)); + }); } PlatformBillingConfigResponse platformBillingConfigFromWrapper( diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart index a2277eaff969..8023514abb74 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/product_details_wrapper_test.dart @@ -51,6 +51,13 @@ void main() { priceAmountMicros: 10, priceCurrencyCode: 'priceCurrencyCode', ), + oneTimePurchaseOfferDetailsList: [ + OneTimePurchaseOfferDetailsWrapper( + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + ), + ], subscriptionOfferDetails: [ SubscriptionOfferDetailsWrapper( basePlanId: 'basePlanId', @@ -84,6 +91,13 @@ void main() { priceAmountMicros: 10, priceCurrencyCode: 'priceCurrencyCode', ), + oneTimePurchaseOfferDetailsList: [ + OneTimePurchaseOfferDetailsWrapper( + formattedPrice: 'formattedPrice', + priceAmountMicros: 10, + priceCurrencyCode: 'priceCurrencyCode', + ), + ], subscriptionOfferDetails: [ SubscriptionOfferDetailsWrapper( basePlanId: 'basePlanId', @@ -115,10 +129,12 @@ void main() { test('operator == of BillingResultWrapper works fine', () { const firstBillingResultInstance = BillingResultWrapper( responseCode: BillingResponse.ok, + subResponseCode: 123, debugMessage: 'debugMessage', ); const secondBillingResultInstance = BillingResultWrapper( responseCode: BillingResponse.ok, + subResponseCode: 123, debugMessage: 'debugMessage', ); expect(firstBillingResultInstance == secondBillingResultInstance, isTrue); diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart index c29edc9e861e..e3b87aa20e3e 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -47,6 +47,10 @@ const ProductDetailsWrapper dummySubscriptionProductDetails = ], ); +final PlatformUnfetchedProduct dummyUnfetchedProduct = PlatformUnfetchedProduct( + productId: 'unfetched', +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -149,6 +153,7 @@ void main() { debugMessage: debugMessage, ), productDetails: [], + unfetchedProductList: [], ), ); @@ -169,6 +174,9 @@ void main() { productDetails: [ convertToPigeonProductDetails(dummyOneTimeProductDetails), ], + unfetchedProductList: [ + dummyUnfetchedProduct, + ], ), ); // Since queryProductDetails makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead @@ -202,6 +210,7 @@ void main() { productDetails: [ convertToPigeonProductDetails(dummyOneTimeProductDetails), ], + unfetchedProductList: [], ), ); // Since queryProductDetails makes 2 platform method calls (one for each ProductType), the result will contain 2 dummyWrapper instead diff --git a/packages/in_app_purchase/in_app_purchase_android/test/test_conversion_utils.dart b/packages/in_app_purchase/in_app_purchase_android/test/test_conversion_utils.dart index b80ab3574580..2493939abc19 100644 --- a/packages/in_app_purchase/in_app_purchase_android/test/test_conversion_utils.dart +++ b/packages/in_app_purchase/in_app_purchase_android/test/test_conversion_utils.dart @@ -15,6 +15,7 @@ PlatformBillingResult convertToPigeonResult(BillingResultWrapper targetResult) { return PlatformBillingResult( responseCode: billingResponseFromWrapper(targetResult.responseCode), debugMessage: targetResult.debugMessage!, + subResponseCode: targetResult.subResponseCode, ); }