Android In-App billing library: doubts about unlocking logic - java

I have succeeded integrating the class BillingClient, starting the connection etc. I can, in fact, make a test purchase of the product from the App (it shows the payment form, buys the product etc.) I implemented it as it is described in this link that explains how to integrate the Google Play Billing Library.
It works, the purchase is performed... but I am clueless about how to implement the unlocking logic! For the record, it is only one product, non-consumable:
private void handlePurchase(Purchase purchase) {
AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = new AcknowledgePurchaseResponseListener() {
#Override
public void onAcknowledgePurchaseResponse(#NonNull BillingResult billingResult) {
// TODO Ok, now what???
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
// TODO Handle the success of the "acknowledge"
// Unlock things etc...
}
}
};
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged()) {
AcknowledgePurchaseParams acknowledgePurchaseParams =
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener);
}
// TODO Unlock from here?
is_premium_unlocked = true;
}
}
I am not sure howto implement the unlock: but for keeping it simple, let's say that the variable is_premium_unlocked will do the job. Will this grant it to be true after closing and reopening the App? I don't think so... Even though this handlePurchase is triggered onPurchaseUpdated, when this event occurrs? Only in new purchase requests? Every time the BillingClient connection is successful? I wonder about all of these things...
So, summarizing: What does my business logic have to do about the "acknowledging"? I am aware that this is some kind of mechanism to prevent duplicated purchases by the same user and stuff like that... but what should my App do about it, beyond what the code samples suggest? But most importantly: how is the app supposed to know that the "unlock product" is already purchased while starting? Is this done by some of the callbacks implemented by documentation's code samples, or have I to implement it my own way? Is there a way of, looking at the ProductDetails to check if it is has been already purchased before, and proceed to execute unlocking logic right away? Is this up to my code, or up to Google Play Billing Service?
The point is, what I have already done following those docs, lets the app "trigger" the unlock just after the product is purchased (AKA is_premium_unlocked = true), but I don't know how is the billingClient supposed to check if the product was already purchased in future connections, or if billingClient is able to do so at all in the first place.

Finally, it was in the docs (I didn't read them hard enough). It looks like it must be done this way, by adding an async checker. So, if I trigger queryPurchasesAsync every time the billingClient connects successfully, I can trigger the unlock from there after app starts:
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(ProductType.INAPP)
.build(),
new PurchasesResponseListener() {
public void onQueryPurchasesResponse(BillingResult billingResult, List purchases) {
is_premium_unlocked = true;
}
}
);

Related

What should you put in `onBillingServiceDisconnected()`?

Here is the method I've got:
public void setupBillingClient() { //connect to google play
billingClient = BillingClient.newBuilder(context)
.enablePendingPurchases()
.setListener(this)
.build();
billingClient.startConnection(new BillingClientStateListener() {
#Override
public void onBillingSetupFinished(#NonNull BillingResult billingResult) {
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
//The BillingClient is setup successfully
loadAllSkus();
}
}
#Override
public void onBillingServiceDisconnected() {
//TODO: implement retry logic to handle lost connections to Google Play by calling startConnection() again
}
});
}
Google says I should "implement retry logic" but doesn't say how. I thought maybe to just call setupBillingClient() inside onBillingServiceDisconnected() but some people said that causes a crash. Also I feel if it was that simple then google would have told us to write that instead of the vague instruction to implement a retry logic.
I also ran into this issue. Google documentation about this is just a mess, (well, like the API itself).
So, here Google says
To implement retry logic, override the onBillingServiceDisconnected()
callback method, and make sure that the BillingClient calls the
startConnection() method to reconnect to Google Play before making
further requests.
Which implies that after the disconnection we have to call startConnection manually.
But here Google says
Called to notify that the connection to the billing service was lost.
Note: This does not remove the billing service connection itself -
this binding to the service will remain active, and you will receive a
call to onBillingSetupFinished(BillingResult) when the billing service
is next running and setup is complete.
Which, in my opinion, absolutely contradicts the previous statement.
From my experience with the billing library, I believe the last statement is more likely to be true. I'm not 100% sure though.
But I can confirm that I saw a disconnect message in the logcat, followed by another message that the billing client was ready. I didn't do any restart actions though. Also, if I tried to startConnection in disconnection callback, then I began to receive two messages in the logcat on each connection/disconnection.
Based on this, I can say that:
You can go here and click on "Not helpful" at the bottom of the page. Or tag them on Twitter, or create an issue on their tracker.
Retry logic, which we are talking about - is not about a connection retry. It's about to retry operation that we tried to perform using the billing client, but it didn't work because it was disconnected.

In-App Review calls Log but does not initiate a dialog

I have a situation where I use this method to call a dialog for the In-App Review, but the dialog does not appear either when the test version is an app, or when the app is live in the Play store. However, LogCat Info shows that the method is called properly when the code is called. Can anyone help with advice or suggestion, thanks.
https://developer.android.com/guide/playcore/in-app-review
Gradle
implementation 'com.google.android.play:core:1.8.0'
onCreate
Log.i("rate", "CALL MANAGER");
askRatings();
Code
void askRatings() {
ReviewManager manager = ReviewManagerFactory.create(this);
com.google.android.play.core.tasks.Task<ReviewInfo> request = manager.requestReviewFlow();
request.addOnCompleteListener(task -> {
if (task.isSuccessful()) {
// We can get the ReviewInfo object
ReviewInfo reviewInfo = task.getResult();
Log.i("rate", "SUCCESS FLOW");
com.google.android.play.core.tasks.Task<Void> flow = manager.launchReviewFlow(this, reviewInfo);
flow.addOnCompleteListener(task2 -> {
// The flow has finished. The API does not indicate whether the user
// reviewed or not, or even whether the review dialog was shown. Thus, no
// matter the result, we continue our app flow.
});
} else {
Log.i("rate", "NOT A SUCCESS FLOW");
}
});
}
Log
2020-08-18 11:17:03.641 13328-13328/my.app I/rate: CALL MANAGER
2020-08-18 11:17:03.764 13328-13328/my.app I/rate: SUCCESS FLOW
I had to use the Internal App Sharing and an Android 10 emulator to see the dialog. In Android 9 and previous I see the same logs as you do, but no review pops up. Anyway, I do see a glitch with the android virtual buttons menu appearing for a second, so I believe that means the flow is working.

How do I validate IAP in android?

I am finding it hard to find complete documentation online. So, atm I have a Cloud Firestore DB. A one Non-Consumable that unlocks a premium version. The short guide I did find recommends:
To validate purchase details on a trusted server, complete the following steps:
Ensure that the device-server handshake is secure.
Check the returned data signature and the orderId, and verify that the orderId is a unique value that you have not previously processed.
Verify that your app's key has signed the INAPP_PURCHASE_DATA that you process.
Validate purchase responses using the ProductPurchase resource (for in-app products) or the SubscriptionPurchase resource (for subscriptions) from the Google Play Developer API. This step is particularly useful because attackers cannot create mock responses to your Play Store purchase requests.
What server do I use? Is cloud fire-store sufficient to complete this validation?
How do I Verify that your app's key has signed the INAPP_PURCHASE_DATA that you process
Do I need to read every order ID every time a user makes a purchase? Couldn't this lead to thousands of reads on the DB very quickly?
Here is the onPurchasesUpdated code block where I am going to implement the validation:
#Override
public void onPurchasesUpdated(BillingResult billingResult, #Nullable List<Purchase> purchases) {
if (billingResult.getResponseCode() == BillingResponseCode.OK
&& purchases != null) {
int index = 0;
for (Purchase purchase : purchases) {
if(purchase.getSignature().equals( /*Somehow check igned the INAPP_PURCHASE_DATA HERE?*/) || purchase.getOrderId().equals(purchases.get(index).getOrderId())) {
//Invalid
getMessage("Invalid. Order cancelled");
return;
} else {
handlePurchase(purchase);
}
}
} else if (billingResult.getResponseCode() == BillingResponseCode.USER_CANCELED) {
getMessage("Payment Cancelled");
} else {
getMessage("Error. Try Again");
}
}
What steps should I take? What do you do to validate IAP's?
Why is the doumentation very poor on this.
Is it even worth the effort? I don't expect this app to vbe downloaded millions of times. Should I just skip it?
You can use a Firebase Cloud Function for receipt validation. You'd set up an HTTP triggered function that you pass the purchase token and have it return if the token is valid or not.
You don't need to store anything in your database (although you may want to). The receipt validation can be done in a function - and there are some well documented open-source examples of this online.
Is it worth the effort? That depends on how much time you wish to invest, and whether you think the cost of users with "fake" purchase tokens outweighs this investment.
Another alternative is to use a hosted solution for your in-app purchase server such as RevenueCat (Disclaimer: I work there).

Firestore admin "listens" to all documents again on reboot

TL;DR
Every time my Fiestore admin server reboots my document listener is triggered for all documents even if I have already listened to the document and processed it. How do I get around this?
End TL;DR
I'm working on building a backend for my Firestore chat application. The basic idea is that whenever a users enters a chat message through a client app the backend server listens for new messages and processes them.
The problem I'm running into is that whenever I reboot my app server the listener is triggered for all of the existing already processed chats. So, it will respond to each chat even though it has already responded previously. I would like the app server to only respond to new chats that it hasn't already responded to.
One idea I have for a work around is to put a boolean flag on each chat document. When the backend processes the chat document it will set the flag. The listener will then only reply to chats that don't have the flag set.
Is this a sound approach or is there a better method? One concern I have is that every time I reboot my app server I will be charged heavily to re-query all of the previous chats. Another concern I have is that listening seems memory bound? If my app scales massively will I have to store all chat documents in memory? That doesn't seem like it will scale well...
//Example listener that processes chats based on whether or not the "hasBeenRepliedTo" flag is set
public void startFirestoreListener() {
CollectionReference docRef = db.collection("chats");
docRef.addSnapshotListener(new EventListener<QuerySnapshot>() {
#Override
public void onEvent(#javax.annotation.Nullable QuerySnapshot queryDocumentSnapshots, #javax.annotation.Nullable FirestoreException e) {
if(e != null) {
logger.error("There was an error listening to changes in the firestore chats collection. E: "+e.getLocalizedMessage());
e.printStackTrace();
}
else if(queryDocumentSnapshots != null && !queryDocumentSnapshots.isEmpty()) {
for(ChatDocument chatDoc : queryDocumentSnapshots.toObjects(ChatDocument.class)) {
if(!chatDoc.getHasBeenRepliedTo() {
//Do some processing
chatDoc.setHasBeenRepliedTo(true); //Set replied to flag
}
else {
//No-op, we've already replied to this chat
}
}
}
}
});
}
Yes, to avoid getting each document all the time, you will have to construct a query that yields only the documents that you know have been processed.
No, you are not charged to query documents. You are charged only to read them, which will happen if your query yields documents.
Yes, you will have to be able to hold all the results of a query in memory.
Your problem will be much easier to solve if you use Cloud Functions to receive events for each new document in a collection. You won't have to worry about any of the above things, and instead just worry about writing a Firestore trigger that does what you want with each new document, and paying for those invocations.

RxJava Subject Cache Result With Retry

I have Observable<FeaturedItemList> getFeatured() that is called everytime the page opened. This function is called from two different components on the same page. Since it retrieves from the network, I cached it and make it shareable with ReplaySubject.
public Observable<FeaturedItemList> getFeatured() {
if(mFeaturedReplaySubject == null) {
mFeaturedReplaySubject = ReplaySubject.create();
getFromNetwork().subscribe(mFeaturedReplaySubject);
}
return mFeaturedReplaySubject;
}
Then I realize that when the request failed for some reasons, if the user come back to that page it will not show any results unless the user killed the app. So I decided to have some retry logic. Here's what I do:
public Observable<FeaturedItemList> getFeatured() {
synchronized (this) {
if (mFeaturedReplaySubject == null) {
mFeaturedReplaySubject = ReplaySubject.create();
getFromNetwork().subscribe(mFeaturedReplaySubject);
return mFeaturedReplaySubject;
} else {
return mFeaturedReplaySubject.onErrorResumeNext(throwable -> {
mFeaturedReplaySubject = null;
return getFeatured();
});
}
}
}
While this works, I'm afraid I'm doing something not good here on there's a case that won't be covered with this approach.
Is there any better approach?
Also for sharing the observable using subject, I read somewhere that I can use connect(), publish(), and share() but I'm not sure how to use it.
The code
private Observable<FeaturedItemList> mFeatured =
// initialized on construction
getFromNetwork()
.retry(3) // number of times to retry
.cache();
public Observable<FeaturedItemList> getFeatured() {
return mFeatured;
}
Explanation
Network source
Your getFromNetwork() function shall return regular observable, which is supposed to access network every time it is subscribed. It shall not access network when it is invoked. For example:
Future<FeaturedItemList> makeAsyncNetworkRequest() {
... initiate network request here ...
}
Observable<FeaturedItemList> getFromNetwork() {
return Observable.fromCallable(this::makeAsyncNetworkRequest)
.flatMap(Observable::fromFuture);
}
Retry
There is a family of .retryXxx() operators, which get activated on errors only. They either re-subscribe to source or pass error down the line, subject to various conditions. In case of no error these operators do nothing. I used simple retry(count) in my example, it will retry specified number of times without delay. You may add a delay or whatever complicated logic using retryWhen() (see here and here for examples).
Cache
cache() operator records the sequence of events and replays it to all new subscribers. The bad thing is that it is not refreshable. It stores the outcome of upstream forever, whether it is success or error, and never retries again.
Alternative to cache()
replay().refCount() replays events to all existing subscribers, but forgets everything as soon as all of them unsubscribe (or complete). It will re-subscribe to getFromNetwork() when new subscriber(s) arrive (with retry on error of course).
Operators mentioned but not needed
share() is a shorthand for publish().refCount(). It allows multiple concurrent subscribers to share single subscription, i.e. makes single call to subscribe() instead of doing it for every subscriber. Both cache() and replay().refCount() incorporate same functionality.

Categories