Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

[in_app_purchase] billingClient launchPriceChangeConfirmationFlow #4077

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.1.4

* Added support for launchPriceChangeConfirmationFlow in the BillingClientWrapper and in InAppPurchaseAndroidPlatformAddition.

## 0.1.3+1

* Add payment proxy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ static final class MethodNames {
static final String ACKNOWLEDGE_PURCHASE =
"BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)";
static final String IS_FEATURE_SUPPORTED = "BillingClient#isFeatureSupported(String)";
static final String LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW =
"BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)";

private MethodNames() {};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.PriceChangeFlowParams;
import com.android.billingclient.api.PurchaseHistoryRecord;
import com.android.billingclient.api.PurchaseHistoryResponseListener;
import com.android.billingclient.api.SkuDetails;
Expand All @@ -41,7 +42,7 @@ class MethodCallHandlerImpl

private static final String TAG = "InAppPurchasePlugin";
private static final String LOAD_SKU_DOC_URL =
"https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale";
"https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/in_app_purchase/README.md#loading-products-for-sale";

@Nullable private BillingClient billingClient;
private final BillingClientFactory billingClientFactory;
Expand Down Expand Up @@ -148,6 +149,9 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) {
case InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED:
isFeatureSupported((String) call.argument("feature"), result);
break;
case InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW:
launchPriceChangeConfirmationFlow((String) call.argument("sku"), result);
break;
default:
result.notImplemented();
}
Expand Down Expand Up @@ -374,6 +378,44 @@ private void updateCachedSkus(@Nullable List<SkuDetails> skuDetailsList) {
}
}

private void launchPriceChangeConfirmationFlow(String sku, MethodChannel.Result result) {
if (activity == null) {
result.error(
"ACTIVITY_UNAVAILABLE",
"launchPriceChangeConfirmationFlow is not available. "
+ "This method must be run with the app in foreground.",
null);
return;
}
if (billingClientError(result)) {
return;
}
// Note that assert doesn't work on Android (see https://stackoverflow.com/a/6176529/5167831 and https://stackoverflow.com/a/8164195/5167831)
// and that this assert is only added to silence the analyser. The actual null check
// is handled by the `billingClientError()` call.
assert billingClient != null;

SkuDetails skuDetails = cachedSkus.get(sku);
if (skuDetails == null) {
result.error(
"NOT_FOUND",
String.format(
"Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s",
sku, LOAD_SKU_DOC_URL),
null);
return;
}

PriceChangeFlowParams params =
new PriceChangeFlowParams.Builder().setSkuDetails(skuDetails).build();
billingClient.launchPriceChangeConfirmationFlow(
activity,
params,
billingResult -> {
result.success(Translator.fromBillingResult(billingResult));
});
}

private boolean billingClientError(MethodChannel.Result result) {
if (billingClient != null) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_FEATURE_SUPPORTED;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED;
import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES;
Expand Down Expand Up @@ -52,6 +53,8 @@
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.PriceChangeConfirmationListener;
import com.android.billingclient.api.PriceChangeFlowParams;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.Purchase.PurchasesResult;
import com.android.billingclient.api.PurchaseHistoryRecord;
Expand Down Expand Up @@ -722,7 +725,7 @@ public void acknowledgePurchase() {
}

@Test
public void endConnection_if_activity_dettached() {
public void endConnection_if_activity_detached() {
InAppPurchasePlugin plugin = new InAppPurchasePlugin();
plugin.setMethodCallHandler(methodChannelHandler);
mockStartConnection();
Expand Down Expand Up @@ -768,6 +771,97 @@ public void isFutureSupported_false() {
verify(result).success(false);
}

@Test
public void launchPriceChangeConfirmationFlow() {
// Set up the sku details
establishConnectedBillingClient(null, null);
String skuId = "foo";
queryForSkus(singletonList(skuId));

BillingResult billingResult =
BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.OK)
.setDebugMessage("dummy debug message")
.build();

// Set up the mock billing client
ArgumentCaptor<PriceChangeConfirmationListener> priceChangeConfirmationListenerArgumentCaptor =
ArgumentCaptor.forClass(PriceChangeConfirmationListener.class);
ArgumentCaptor<PriceChangeFlowParams> priceChangeFlowParamsArgumentCaptor =
ArgumentCaptor.forClass(PriceChangeFlowParams.class);
doNothing()
.when(mockBillingClient)
.launchPriceChangeConfirmationFlow(
any(),
priceChangeFlowParamsArgumentCaptor.capture(),
priceChangeConfirmationListenerArgumentCaptor.capture());

// Call the methodChannelHandler
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("sku", skuId);
methodChannelHandler.onMethodCall(
new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result);

// Verify the price change params.
PriceChangeFlowParams priceChangeFlowParams = priceChangeFlowParamsArgumentCaptor.getValue();
assertEquals(skuId, priceChangeFlowParams.getSkuDetails().getSku());

// Set the response in the callback
PriceChangeConfirmationListener priceChangeConfirmationListener =
priceChangeConfirmationListenerArgumentCaptor.getValue();
priceChangeConfirmationListener.onPriceChangeConfirmationResult(billingResult);

// Verify we pass the response to result
verify(result, never()).error(any(), any(), any());
ArgumentCaptor<HashMap> resultCaptor = ArgumentCaptor.forClass(HashMap.class);
verify(result, times(1)).success(resultCaptor.capture());
assertEquals(fromBillingResult(billingResult), resultCaptor.getValue());
}

@Test
public void launchPriceChangeConfirmationFlow_withoutActivity_returnsActivityUnavailableError() {
// Set up the sku details
establishConnectedBillingClient(null, null);
String skuId = "foo";
queryForSkus(singletonList(skuId));

methodChannelHandler.setActivity(null);

// Call the methodChannelHandler
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("sku", skuId);
methodChannelHandler.onMethodCall(
new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result);
verify(result, times(1)).error(eq("ACTIVITY_UNAVAILABLE"), any(), any());
}

@Test
public void launchPriceChangeConfirmationFlow_withoutSkuQuery_returnsNotFoundError() {
// Set up the sku details
establishConnectedBillingClient(null, null);
String skuId = "foo";

// Call the methodChannelHandler
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("sku", skuId);
methodChannelHandler.onMethodCall(
new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result);
verify(result, times(1)).error(eq("NOT_FOUND"), contains("sku"), any());
}

@Test
public void launchPriceChangeConfirmationFlow_withoutBillingClient_returnsUnavailableError() {
// Set up the sku details
String skuId = "foo";

// Call the methodChannelHandler
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("sku", skuId);
methodChannelHandler.onMethodCall(
new MethodCall(LAUNCH_PRICE_CHANGE_CONFIRMATION_FLOW, arguments), result);
verify(result, times(1)).error(eq("UNAVAILABLE"), contains("BillingClient"), any());
}

private ArgumentCaptor<BillingClientStateListener> mockStartConnection() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("handle", 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ const bool _kAutoConsume = true;

const String _kConsumableId = 'consumable';
const String _kUpgradeId = 'upgrade';
const String _kSilverSubscriptionId = 'subscription_silver';
const String _kGoldSubscriptionId = 'subscription_gold';
const String _kSilverSubscriptionId = 'subscription_silver1';
const String _kGoldSubscriptionId = 'subscription_gold1';
const List<String> _kProductIds = <String>[
_kConsumableId,
_kUpgradeId,
Expand Down Expand Up @@ -251,7 +251,21 @@ class _MyAppState extends State<_MyApp> {
productDetails.description,
),
trailing: previousPurchase != null
? Icon(Icons.check)
? IconButton(
onPressed: () {
final InAppPurchaseAndroidPlatformAddition addition =
InAppPurchasePlatformAddition.instance
as InAppPurchaseAndroidPlatformAddition;
var skuDetails =
(productDetails as GooglePlayProductDetails)
.skuDetails;
addition
.launchPriceChangeConfirmationFlow(
sku: skuDetails.sku)
.then((value) => print(
"confirmationResponse: ${value.responseCode}"));
},
icon: Icon(Icons.upgrade))
: TextButton(
child: Text(productDetails.price),
style: TextButton.styleFrom(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,26 @@ class BillingClient {
return result ?? false;
}

/// Initiates a flow to confirm the change of price for an item subscribed by the user.
///
/// When the price of a user subscribed item has changed, launch this flow to take users to
/// a screen with price change information. User can confirm the new price or cancel the flow.
///
/// The skuDetails needs to have already been fetched in a [querySkuDetails]
/// call.
Future<BillingResultWrapper> launchPriceChangeConfirmationFlow(
{required String sku}) async {
assert(sku != null);
final Map<String, dynamic> arguments = <String, dynamic>{
'sku': sku,
};
return BillingResultWrapper.fromJson((await channel.invokeMapMethod<String,
dynamic>(
'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)',
arguments)) ??
<String, dynamic>{});
}

/// The method call handler for [channel].
@visibleForTesting
Future<void> callHandler(MethodCall call) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,16 @@ class InAppPurchaseAndroidPlatformAddition
Future<bool> isFeatureSupported(BillingClientFeature feature) async {
return _billingClient.isFeatureSupported(feature);
}

/// Initiates a flow to confirm the change of price for an item subscribed by the user.
///
/// When the price of a user subscribed item has changed, launch this flow to take users to
/// a screen with price change information. User can confirm the new price or cancel the flow.
///
/// The skuDetails needs to have already been fetched in a
/// [InAppPurchaseAndroidPlatform.queryProductDetails] call.
Future<BillingResultWrapper> launchPriceChangeConfirmationFlow(
{required String sku}) {
return _billingClient.launchPriceChangeConfirmationFlow(sku: sku);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: in_app_purchase_android
description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs.
repository: https://github.com/flutter/plugins/tree/master/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.1.3+1
version: 0.1.4

environment:
sdk: ">=2.12.0 <3.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,4 +574,44 @@ void main() {
expect(arguments['feature'], equals('subscriptions'));
});
});

group('launchPriceChangeConfirmationFlow', () {
const String launchPriceChangeConfirmationFlowMethodName =
'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)';

final expectedBillingResultPriceChangeConfirmation = BillingResultWrapper(
responseCode: BillingResponse.ok,
debugMessage: 'dummy message',
);

test('serializes and deserializes data', () async {
stubPlatform.addResponse(
name: launchPriceChangeConfirmationFlowMethodName,
value:
buildBillingResultMap(expectedBillingResultPriceChangeConfirmation),
);

expect(
await billingClient.launchPriceChangeConfirmationFlow(
sku: dummySkuDetails.sku,
),
equals(expectedBillingResultPriceChangeConfirmation),
);
});

test('passes sku to launchPriceChangeConfirmationFlow', () async {
stubPlatform.addResponse(
name: launchPriceChangeConfirmationFlowMethodName,
value:
buildBillingResultMap(expectedBillingResultPriceChangeConfirmation),
);
await billingClient.launchPriceChangeConfirmationFlow(
sku: dummySkuDetails.sku,
);
final MethodCall call = stubPlatform
.previousCallMatching(launchPriceChangeConfirmationFlowMethodName);
expect(call.arguments,
equals(<dynamic, dynamic>{'sku': dummySkuDetails.sku}));
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,44 @@ void main() {
expect(arguments['feature'], equals('subscriptions'));
});
});

group('launchPriceChangeConfirmationFlow', () {
const String launchPriceChangeConfirmationFlowMethodName =
'BillingClient#launchPriceChangeConfirmationFlow (Activity, PriceChangeFlowParams, PriceChangeConfirmationListener)';
const dummySku = 'sku';

final expectedBillingResultPriceChangeConfirmation = BillingResultWrapper(
responseCode: BillingResponse.ok,
debugMessage: 'dummy message',
);

test('serializes and deserializes data', () async {
stubPlatform.addResponse(
name: launchPriceChangeConfirmationFlowMethodName,
value:
buildBillingResultMap(expectedBillingResultPriceChangeConfirmation),
);

expect(
await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow(
sku: dummySku,
),
equals(expectedBillingResultPriceChangeConfirmation),
);
});

test('passes sku to launchPriceChangeConfirmationFlow', () async {
stubPlatform.addResponse(
name: launchPriceChangeConfirmationFlowMethodName,
value:
buildBillingResultMap(expectedBillingResultPriceChangeConfirmation),
);
await iapAndroidPlatformAddition.launchPriceChangeConfirmationFlow(
sku: dummySku,
);
final MethodCall call = stubPlatform
.previousCallMatching(launchPriceChangeConfirmationFlowMethodName);
expect(call.arguments, equals(<dynamic, dynamic>{'sku': dummySku}));
});
});
}