Play Billing (Java)
Suppose RecipeBox wants to sell a one-time "RecipeBox Pro" unlock that removes ads and adds unlimited recipe categories. If the app is distributed through Google Play, this isn't just a convenient way to handle it — it's a genuine Play Store policy requirement. Any app on Play that sells digital content or functionality consumed inside the app must use the Play Billing Library to process that payment. You cannot route it through your own payment gateway or a third-party checkout page for content used within the app itself.
Two Product Types
| Type | Description | RecipeBox example |
|---|---|---|
| In-app product | A one-time purchase. The user pays once and keeps the benefit forever. | recipebox_pro_unlock — removes ads, unlocks unlimited categories. |
| Subscription | A recurring charge on a billing cycle (monthly/yearly) until cancelled. | recipebox_premium_packs — a monthly premium recipe pack subscription. |
Setting Up Products in the Play Console
Before writing any Android code, the products themselves are configured outside Android Studio, in the Google Play Console: you create a product ID (e.g. recipebox_pro_unlock), set its price, and — for subscriptions — define the billing period and any free trial. The Android code below only ever refers to products by that ID; it never sets prices or billing periods itself.
Adding the Dependency
// app/build.gradle
dependencies {
implementation 'com.android.billingclient:billing:6.2.1'
}
Connecting a BillingClient
// In RecipeBox's BillingManager.java
public class BillingManager {
private final BillingClient billingClient;
public BillingManager(Context context, PurchasesUpdatedListener listener) {
billingClient = BillingClient.newBuilder(context)
.setListener(listener)
.enablePendingPurchases()
.build();
}
public void connect() {
billingClient.startConnection(new BillingClientStateListener() {
@Override
public void onBillingSetupFinished(BillingResult result) {
if (result.getResponseCode() == BillingResponseCode.OK) {
queryProducts();
}
}
@Override
public void onBillingServiceDisconnected() {
// Retry connection on the next user action
}
});
}
}
- enablePendingPurchases() is required so RecipeBox can handle purchases that need extra steps (e.g. cash payment methods) to complete.
- The PurchasesUpdatedListener is where every purchase result — new or restored — eventually arrives.
Querying Available Products
private void queryProducts() {
QueryProductDetailsParams.Product product =
QueryProductDetailsParams.Product.newBuilder()
.setProductId("recipebox_pro_unlock")
.setProductType(BillingClient.ProductType.INAPP)
.build();
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
.setProductList(Collections.singletonList(product))
.build();
billingClient.queryProductDetailsAsync(params, (result, productDetailsList) -> {
if (!productDetailsList.isEmpty()) {
launchPurchaseFlow(productDetailsList.get(0));
}
});
}
Launching the Purchase Flow
private void launchPurchaseFlow(ProductDetails productDetails) {
BillingFlowParams.ProductDetailsParams detailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.build();
BillingFlowParams flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(Collections.singletonList(detailsParams))
.build();
billingClient.launchBillingFlow(activity, flowParams);
}
Handling the Result & Acknowledging the Purchase
Every purchase — whether it just completed or was restored from an earlier session — arrives in the PurchasesUpdatedListener passed to the BillingClient when it was built.
public class MainActivity extends AppCompatActivity
implements PurchasesUpdatedListener {
@Override
public void onPurchasesUpdated(BillingResult result, List<Purchase> purchases) {
if (result.getResponseCode() == BillingResponseCode.OK && purchases != null) {
for (Purchase purchase : purchases) {
handlePurchase(purchase);
}
}
}
private void handlePurchase(Purchase purchase) {
if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged()) {
AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.getPurchaseToken())
.build();
billingClient.acknowledgePurchase(params, ackResult -> {
// Now safe to unlock RecipeBox Pro features
unlockProFeatures();
});
}
}
}
}
Server-Side Verification (Conceptual)
The flow above trusts the purchase object handed back on-device, which is fine for learning and for small apps. A production app selling something valuable is better served by sending the purchase token to your own backend and verifying it against the Google Play Developer API there. Server-side verification is harder to spoof than anything checked purely on the device, since a modified or rooted client can't fake a token that your server independently confirms with Google. RecipeBox doesn't need this for a simple Pro unlock, but it's worth knowing the option exists before scaling up a paid feature.