My app has in-app subscription purchases but I am feeling really confused regarding the whole implementation process.
When the app opens I am calling onPurchasesUpdated(int responseCode, #Nullable List<Purchase> purchases) to check if the user has any active purchases. If the purchase list is null, the app assumes the user has not purchased anything.
Later when user decides to purchase something app calls:
mBillingClient = BillingClient.newBuilder(view.getContext()).setListener(new PurchasesUpdatedListener()
Once the purchase is made again onPurchasesUpdated(int responseCode, #Nullable List<Purchase> purchases) is called, however, once I reopen the app nothing works, its all back to normal (free version) like user purchased nothing.
Also, user purchase data wasn't stored in the cloud (firebase real-time database). Already three users have made their purchase and it's in Three Day Trial period.
Have you looked at the sample on GitHub? When user opens your app you are supposed to call queryPurchases. Here is an example:
fun queryPurchasesAsync() {
Log.d(LOG_TAG, "queryPurchasesAsync called")
val purchasesResult = HashSet<Purchase>()
var result = playStoreBillingClient.queryPurchases(BillingClient.SkuType.INAPP)
Log.d(LOG_TAG, "queryPurchasesAsync INAPP results: ${result?.purchasesList?.size}")
result?.purchasesList?.apply { purchasesResult.addAll(this) }
if (isSubscriptionSupported()) {
result = playStoreBillingClient.queryPurchases(BillingClient.SkuType.SUBS)
result?.purchasesList?.apply { purchasesResult.addAll(this) }
Log.d(LOG_TAG, "queryPurchasesAsync SUBS results: ${result?.purchasesList?.size}")
}
processPurchases(purchasesResult)
}
And you should make this call as soon as you establish connection with the BillingClient service:
/**
* This is the callback for when connection to the Play [BillingClient] has been successfully
* established. It might make sense to get [SkuDetails] and [Purchases][Purchase] at this point.
*/
override fun onBillingSetupFinished(billingResult: BillingResult) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
Log.d(LOG_TAG, "onBillingSetupFinished successfully")
querySkuDetailsAsync(BillingClient.SkuType.INAPP, GameSku.INAPP_SKUS)
querySkuDetailsAsync(BillingClient.SkuType.SUBS, GameSku.SUBS_SKUS)
queryPurchasesAsync()
}
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> {
//Some apps may choose to make decisions based on this knowledge.
Log.d(LOG_TAG, billingResult.debugMessage)
}
else -> {
//do nothing. Someone else will connect it through retry policy.
//May choose to send to server though
Log.d(LOG_TAG, billingResult.debugMessage)
}
}
}
Related
What I want
I want to integrate Google Play in app purchases into my flutter app, the product I want to sell is a consumable. Right now I am using the in_app_purchase: (0.3.4+16) package.
What I did
Create the product in the google play developer console
Set up the project according to the documentation of the in_app_purchase package
Implemented some logic to buy a consumable item (see below)
Built an alpha release and uploaded it to the alpha test track in order to test it
Created a new google account on my developer phone, registered it as tester and downloaded the alpha version
Purchased with a "test card, always approves"
Purchased with my PayPal account
Expected and actual result
I expect the payment to work and all api calls to return ok.
When I initiate a purchase on my phone the purchase flow starts and I can select the desired payment method. After I accept the payment the _listenToPurchaseUpdated(...) method is called, as expected.
However, the call to InAppPurchaseConnection.instance.completePurchase(p) returns a BillingResponse.developerError and I get the following debug messages:
W/BillingHelper: Couldn't find purchase lists, trying to find single data.
I/flutter: result: BillingResponse.developerError (Purchase is in an invalid state.)
This error comes with the "test card, always approves" and also when I start a real transaction using PayPal. For the PayPal purchase I got a confirmation Email, that the transaction was successful.
In the documentation it says:
Warning! Failure to call this method and get a successful response within 3 days of the purchase will result a refund on Android.
Summarized question
How can I get the call to InAppPurchaseConnection.instance.completePurchase(p) to return a successful result?
The purchase implementation
The code to setup in app purchases is implemented as shown in the documentation:
InAppPurchaseConnection.enablePendingPurchases();
Stream<List<PurchaseDetails>> purchaseUpdated = InAppPurchaseConnection.instance.purchaseUpdatedStream;
_subscription = purchaseUpdated.listen(_listenToPurchaseUpdated, onDone: () {
_subscription.cancel();
}, onError: (error) {
// handle error here.
});
...
Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
for (var p in purchaseDetailsList) {
// Code to validate the payment
if (!p.pendingCompletePurchase) continue;
var result = await InAppPurchaseConnection.instance.completePurchase(p);
if (result.responseCode != BillingResponse.ok) {
print("result: ${result.responseCode} (${result.debugMessage})");
}
}
}
To buy a consumable I have this method which queries the product details and calls buyConsumable(...)
Future<bool> _buyConsumableById(String id) async {
final ProductDetailsResponse response = await InAppPurchaseConnection
.instance
.queryProductDetails([id].toSet());
if (response.notFoundIDs.isNotEmpty || response.productDetails.isEmpty) {
return false;
}
List<ProductDetails> productDetails = response.productDetails;
final PurchaseParam purchaseParam = PurchaseParam(
productDetails: productDetails[0],
);
return await InAppPurchaseConnection.instance.buyConsumable(
purchaseParam: purchaseParam,
);
}
The solution is to not call the completePurchase(...) method for consumable purchases. By default the library consumes the purchase for you which implicitly acts as a call to completePurchase(...).
Background
The call to InAppPurchaseConnection.instance.buyConsumable(...) has an optional boolean parameter autoConsume which is always true. This means that, on android, the purchase is consumed right before the callback to the purchaseUpdatedStream.
The documentation of the completePurchase method says the following:
The [consumePurchase] acts as an implicit [completePurchase] on Android
Code to fix the problem
Future<void> _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) async {
for (var p in purchaseDetailsList) {
// Code to validate the payment
if (!p.pendingCompletePurchase) continue;
if (_isConsumable(p.productID)) continue; // Determine if the item is consumable. If so do not consume it
var result = await InAppPurchaseConnection.instance.completePurchase(p);
if (result.responseCode != BillingResponse.ok) {
print("result: ${result.responseCode} (${result.debugMessage})");
}
}
}
I've integrated the Smooch/Sunshine Conversations SDK into our app.
On the most part, it works. However I've got a bit of an issue in a failure scenario:
User is logged in (both to our service, and smooch)
Our serverside dies for whatever reason, meaning JWT temporarily can't be fetched
Conversation view shows "Cannot connect to server" (as expected)
Our serverside recovers ... valid JWT's returned on request
User tries to trigger a conversation in the app, and they continue to see "Cannot connect to server" indefinitely (even after moving back from the conversation activity and back into it).
The Smooch SDK never recovers from this. The only way to solve it is to kill and restart the app.
I'm using the latest SDK version 7.0.3, and the vanilla ConversationActivity (I've not subclassed this or anything)
I've tried the following:
Re initialising Smooch immediately before moving into the ConversationActivity
Calling login immediately before moving into the ConversationActivity
Any ideas?
Code:
// This is in the Application class, as recommended
fun initialiseSmooch(application: Application) {
GlobalScope.launch {
Log.i(TAG, "Initialising Smooch")
val settings = Settings("INTEGRATION_ID")
settings.authenticationDelegate = getAuthenticationDelegate()
Smooch.init(application, settings, getInitialisationCallback())
}
}
private fun getInitialisationCallback(): (SmoochCallback.Response<InitializationStatus>) -> Unit {
return { response ->
if (response.data === InitializationStatus.SUCCESS) {
Log.i(TAG, "Smooch initialised successfully")
} else {
Log.e(TAG, "Smooch initialization failed: ${response.error}")
}
}
}
/**
* This basically tells the Smooch SDK what to do if the JWT is rejected. Basically it goes
* and fetches a new token from our API.
*/
private fun getAuthenticationDelegate(): AuthenticationDelegate {
return AuthenticationDelegate(function = { authenticationError, authenticationCallback ->
if (authenticationError != null && authenticationError.data != null) {
Log.w(TAG, authenticationError.data)
}
if(AppResources.repository.getUserId() == null){
Log.i(TAG, "Authentication error. User isn't logged in, so shouldn't be logged in to Smooch either.")
logoutSmoochUser()
} else {
Log.i(TAG, "Authentication error. Getting new Smooch token.")
getSmoochToken { token -> authenticationCallback.updateToken(token) }
}
})
}
private fun getSmoochToken(callback: (String) -> Unit) {
// Fetches token from API. If successful, callback is called
// If unsuccessful, callback isn't called. This won't hang forever, it has a timeout.
}
// And to start the conversation
private fun proceedToConversation() {
ConversationActivity.builder().show(this)
}
Please ensure you've implemented authentication delegates to automatically handle cases where JWTs are expired: https://docs.smooch.io/guide/authenticating-users/#expiring-jwts-on-sdks
Once your backend issuing the JWTs comes back up, I'd start recovery attempts with a login() call.
Finally, 10s JWT expiry is very short indeed.
I was wondering could you help. I followed the instructions at https://developer.android.com/google/play/billing/integrate, but I cannot seem to get the purchase flow working. The billing seems to setup ok, but when I try to query for my in-app products, the list is always returning empty. Can someone please help?
In my app level build.gradle file, I have included the Google Billing SDK:
implementation 'com.android.billingclient:billing:3.0.0'
Then I have created an activity to test out the code. It first initialises the BillingClient and starts the connection. The connection seems to finish the setup correctly. Once setup correctly, I then try to query the products that I have available in my Google Play Console under 'Store presence' > 'In-app products' > 'Manage products'
The following is then the code in the Activity that should kick off the process and return the SkuDetails list, but unfortunately it is returning back empty.
private BillingClient billingClient;
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_billing);
this.billingClient = BillingClient.newBuilder(this)
.enablePendingPurchases()
.setListener(this.purchaseUpdateListener)
.build();
this.billingClient.startConnection(billingClientStateListener);
}
private PurchasesUpdatedListener purchaseUpdateListener = new PurchasesUpdatedListener() {
#Override
public void onPurchasesUpdated(#NonNull BillingResult billingResult, #Nullable List<Purchase> list) {
Log.d("Billing", "onPurchasesUpdated - List Size: " + list.size());
}
};
private BillingClientStateListener billingClientStateListener = new BillingClientStateListener() {
#Override
public void onBillingSetupFinished(#NonNull BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
Log.d("Billing", "onBillingSetupFinished - OK");
queryProducts();
} else {
Log.d("Billing", "onBillingSetupFinished - Something wrong response Code: " + billingResult.getResponseCode());
}
}
#Override
public void onBillingServiceDisconnected() {
Log.d("Billing", "Service disconnected");
}
};
private void queryProducts() {
List<String> productIdsList = new ArrayList<>();
productIdsList.add("test.billing.001");
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
params.setSkusList(productIdsList).setType(BillingClient.SkuType.INAPP);
this.billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
#Override
public void onSkuDetailsResponse(#NonNull BillingResult billingResult, #Nullable List< SkuDetails > list) {
Log.d("Billing", "onSkuDetailsResponse - List Size: " + list.size());
}
});
}
So for anyone who is having similar issues, it seems that (well in my case anyways) that my app needed to be successfully published before I could retrieve the in-app products from the app. Once my app was published, I was then able to query and use the in-app products.
According to Maxim Alov comment for Billing 5.0.0, the problem is in invitations for testers.
My steps to fix this problem:
Open tester's invitation link and accept
Open app via link from previous step
After two these steps (in my case second helped) all products started to come
I'm using billing-lib-5.0.0 and also had the same issue -
queryProductDetails() was always empty on my release builds, let alone
debug builds. I'd actually added all my test gmail emails to list of
testers for Closed Testing, and also LICENSED all of them. No effect.
Eventually, I recognized that the link to my test app is not generated
on Play Console Closed Testing track page. I recreated the track, this
time for Internal Testing, and the link has appeared. Then I logged in
to each of my gmails and accepted to be a tester from that link. After
doing that, products started to come, in IDE
I am trying to create a restore purchase system. I want, the user can reach its bought products whichever device he/she logged in. So I use "queryPurchaseHistoryAsync()" method when app launches. My problem starts here.
With new implementation of Google, On contrary to the documentation, queryPurchaseHistoryAsync() parameters changed. Now it takes list of PurchaseHistoryRecord objects as parameter instead of list of Purchase objects.
Android studio can not resolve the method stated in the documentation. With new queryPurchaseHistoryAsync() I couldn't find anyway to check purchases state.( if it is purchased, canceled or pending). That I was able to do with Purchase object with "purchase.getPurchaseState()" method.
Documentation of queryPurchaseHistoryAsync()
billingClient.queryPurchaseHistoryAsync(SkuType.INAPP,
new PurchaseHistoryResponseListener() {
#Override
public void onPurchaseHistoryResponse(BillingResult billingResult,
List<Purchase> purchasesList) {
if (billingResult.getResponseCode() == BillingResponse.OK
&& purchasesList != null) {
for (Purchase purchase : purchasesList) {
// Process the result.
}
}
}
});
My implementation
implementation 'com.android.billingclient:billing:2.0.3'
queryPurchaseHistoryAsync() Method in my app
billingClient.queryPurchaseHistoryAsync(BillingClient.SkuType.INAPP,
new PurchaseHistoryResponseListener() {
#Override
public void onPurchaseHistoryResponse(BillingResult billingResult, List<PurchaseHistoryRecord> purchaseHistoryRecordList) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK
&& purchaseHistoryRecordList != null) {
for (PurchaseHistoryRecord purchaseHistoryRecord : purchaseHistoryRecordList) {
HandleOldGetting(purchaseHistoryRecord.getSku());
}
}
}
Release Note of Google(05-2019):
"To minimize confusion, queryPurchaseHistoryAsync() now returns a
PurchaseHistoryRecord object instead of a Purchase object. The PurchaseHistoryRecord object is the same as a Purchase object, except that it reflects only the values returned by queryPurchaseHistoryAsync() and does not contain the autoRenewing, orderId, and packageName fields. Note that nothing has changed with the returned data—queryPurchaseHistoryAsync() returns the same data as before."
But neither release note nor documentation state how to check Purchase State with PurchaseHistoryRecord.
Thank you for reading this, any help is appreciated.
So far, I have been using queryPurchases() to restore purchase automatically as it does not require any networking.
Google play app's cache related to account is updating for all devices. In many cases you won't need call to queryPurchaseHistoryAsync call for restoration.
As stated in #bospehre comment. It has drawback as it depends on the cache. So we still need to check purchases situations and restore them with network call.
For queryPurchaseHistory Async call, we can get the purchase sku and token. If you are using server to hold subscription datas as Google recommends. You can check this subscription's situations via your server.
Here is an example for restoring the latest subscription of the user.
billingManager.billingClient.queryPurchaseHistoryAsync(BillingClient.SkuType.SUBS) { billingResult, purchaseHistoryRecords ->
if (purchaseHistoryRecords != null) {
var activePurchaseRecord : PurchaseHistoryRecord? = null
if (purchaseHistoryRecords.size > 0) {
// Get the latest subscription. It may differ for developer needs.
for (purchaseHistoryRecord in purchaseHistoryRecords) {
Log.d(billingLogs, "Purchase History Record : $purchaseHistoryRecord")
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
if (subSkuListHelper.getSkuList().contains(purchaseHistoryRecord.sku)
) {
if (activePurchaseRecord == null) {
activePurchaseRecord = purchaseHistoryRecord
} else {
if (purchaseHistoryRecord.purchaseTime > activePurchaseRecord.purchaseTime) {
activePurchaseRecord = purchaseHistoryRecord
}
}
}
}
}
Toast.makeText(
this,
"Subscription Purchases found, Checking validity...",
Toast.LENGTH_SHORT
).show()
// Make a network call with sku and purchaseToken to get subscription info
//Subscription Data Fetch is a class that handling the networking
activePurchaseRecord?.let { SubscriptionDataFetch(
this,
billingManager.billingClient
)
.executeNetWorkCall(
getString(R.string.ubscription_check_endpoint),
it.sku,
it.purchaseToken
)
}
}
else {
Log.d(billingLogs, "Purchase History Record not found size 0") }
}
else {
Toast.makeText(
this,
"Purchase not found",
Toast.LENGTH_SHORT
).show()
Log.d(billingLogs, "Purchase History Record not found null")
}
}
QueryInventoryFinishedListener of IabHelper has not returned the expired subscription items.
On the other hand, PurchaseHistoryResponseListener of Google Play Billing Library seems to receive all purchased items, which is including expired items.
On Google Play Billing Library, we have to check the purchased date of PurchaseHistoryResponseListener and each expiration date of items?
queryPurchases vs queryPurchaseHistoryAsync
Generally, we should use queryPurchases(String skuType), which does not returns expired items. queryPurchaseHistoryAsync returns enabled and disabled items, as you see the documentation like following.
queryPurchases
Get purchases details for all the items bought within your app. This method uses a cache of Google Play Store app without initiating a network request.
queryPurchaseHistoryAsync
Returns the most recent purchase made by the user for each SKU, even if that purchase is expired, canceled, or consumed.
About queryPurchaseHistoryAsync
I could not image the use case for queryPurchaseHistoryAsync. If we need to use queryPurchaseHistoryAsync, we need the implementation to check if it is expired or not.
private PurchaseHistoryResponseListener listener = new PurchaseHistoryResponseListener() {
#Override
public void onPurchaseHistoryResponse(int responseCode, List<Purchase> purchasesList) {
for (Purchase purchase : purchasesList) {
if (purchase.getSku().equals("sku_id")) {
long purchaseTime = purchase.getPurchaseTime();
// boolean expired = purchaseTime + period < now
}
}
}
};
Purchase object does not have the information of period, so the above period must be acquired from BillingClient.querySkuDetailsAsync or be hard-coded. The following is sample implementation to use querySkuDetailsAsync.
List<String> skuList = new ArrayList<>();
skuList.add("sku_id");
SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS);
billingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
#Override
public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
if (skuDetailsList == null) {
return;
}
for (SkuDetails skuDetail : skuDetailsList) {
if (skuDetail.getSku().equals("sku_id")) {
String period = skuDetail.getSubscriptionPeriod();
}
}
}
});