inappbilling is a Google Play Billing library (Play Billing Library 9.1.0) that demonstrates how to implement in-app purchases and subscriptions in your Android application using Kotlin coroutines and Flow.
Important
Version 4.0.0 is a breaking rewrite. The listener-based API (BillingConnectionListener, BillingPurchaseListener, etc.) has been replaced with suspend funs and Flows, and moved to Play Billing Library 9.1.0. See Migrating from 3.x below.
In your project-level build.gradle or settings.gradle file, add the JitPack repository:
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" }
}
In your app-level build.gradle file, add the library dependency. Use the latest version:
implementation 'com.github.hypersoftdev:inappbilling:x.x.x'
BillingManager is meant to be created once, as a singleton scoped to your Application — not per-Activity/ViewModel. Play's purchase callback can fire at any time, including after the screen that started a purchase is gone, so BillingManager owns its own internal coroutine scope rather than accepting one from the caller. Connect once, early, and let every screen just observe state from then on.
class MyApplication : Application() {
val billingManager by lazy { BillingManager(this) }
override fun onCreate() {
super.onCreate()
billingManager
.setNonConsumables(listOf("inapp_nonconsumable_1"))
.setConsumables(listOf("inapp_consumable_1", "inapp_consumable_2"))
.setSubscriptions(listOf("subs_id_1", "subs_id_2"))
appScope.launch { billingManager.connect() }
}
}On a successful connect(), productsState and purchasesState (below) refresh automatically in the background — there's no separate "now fetch products/purchases" step to remember.
connect()'s return value is a one-shot snapshot — useful right after you call it, but it won't tell you if the connection later drops and (with enableAutoServiceReconnection() always on internally) reconnects. For that, collect connectionState: StateFlow<ConnectionState> instead:
lifecycleScope.launch {
billingManager.connectionState.collect { state ->
when (state) {
ConnectionState.DISCONNECTED -> { /* not connected yet, or the connection dropped */ }
ConnectionState.CONNECTING -> { /* connection attempt in progress */ }
ConnectionState.CONNECTED -> { /* ready — safe to call purchaseXxx()/refreshXxx() */ }
}
}
}A few things worth knowing about it:
- It's a
StateFlow, so any new collector immediately gets the current state, then every change after that — you don't need to callconnect()again just to start observing. - Gate purchase buttons on
connectionState == CONNECTED(orUiStatefromproductsState/purchasesState, which can only reachSuccessonce connected) rather than assumingconnect()having returned once means the connection stays up forever. - This is purely about the connection, not your data —
productsState/purchasesStatestaying onLoadingwhileconnectionStateisCONNECTINGis expected; they refresh onceconnectionStatereachesCONNECTED.
lifecycleScope.launch {
billingManager.purchasesState.collect { state ->
when (state) {
is UiState.Loading -> { /* still resolving — don't assume non-premium yet */ }
is UiState.Success -> { /* derive your premium/owned-products check from state.data */ }
is UiState.Error -> { /* e.g. offline — decide your own fallback policy */ }
}
}
}purchasesState also re-refreshes automatically right after any purchase settles (whether initiated in this session or resumed from a pending state), so this collector is the single source of truth for "is the user premium" — no manual re-fetch needed after a purchase completes.
Access comprehensive details of the currently purchased item using the PurchaseDetail class:
/**
productId: Product Id for both inapp/subs (e.g. product_ads/product_weekly_ads)
planId: Plan Id for subs (e.g. plan_weekly_ads)
productTitle: Title of the Product
planTitle: Title of the Plan
productType: Product purchase type (e.g. InApp/Subs)
purchaseToken: a unique token for this purchase
purchaseTime: For subscriptions, this is the subscription signup time. It won't change after renewal.
purchaseTimeMillis: UnixTimeStamp (starts from Jan 1, 1970)
isAutoRenewing: Only in case of 'BillingClient.ProductType.SUBS'
*/
data class PurchaseDetail(
val productId: String,
val productType: ProductType,
val purchaseToken: String,
var productTitle: String?,
var planTitle: String?,
var planId: String?,
val purchaseTime: String,
val purchaseTimeMillis: Long,
val isAutoRenewing: Boolean,
val isAcknowledged: Boolean,
)Because productsState was already warming up since connect(), a paywall shown 1-2 screens later usually finds it already resolved — collect it, don't fetch-and-wait:
lifecycleScope.launch {
billingManager.productsState.collect { state ->
when (state) {
is UiState.Loading -> showSpinner()
is UiState.Error -> showError(state.message)
is UiState.Success -> state.data.forEach { productDetail ->
if (productDetail.productType == ProductType.inapp) {
when (productDetail.productId) {
"inapp_product_id_1" -> { /* Handle in-app product 1 */ }
"inapp_product_id_2" -> { /* Handle in-app product 2 */ }
}
} else {
when (productDetail.productId) {
"subs_product_id_1" -> if (productDetail.planId == "subs-plan-id-1") {
val freeTrial = productDetail.pricingDetails.find { it.recurringMode == RecurringMode.FREE }
val discounted = productDetail.pricingDetails.find { it.recurringMode == RecurringMode.DISCOUNTED }
val original = productDetail.pricingDetails.find { it.recurringMode == RecurringMode.ORIGINAL }
}
"subs_product_id_2" -> if (productDetail.planId == "subs-plan-id-2") { /* Handle plan2 subscription */ }
"subs_product_id_3" -> if (productDetail.planId == "subs-plan-id-3") { /* Handle plan3 subscription */ }
}
}
}
}
}
}If you register a new set of IDs later (or just want to force a re-fetch, e.g. pull-to-refresh), call billingManager.refreshProducts() — it republishes productsState the same way the automatic post-connect refresh does.
Or query a specific product (read from the in-memory cache — no network call):
lifecycleScope.launch {
billingManager.getProductDetail("subs_id_1", "plan_id_1")
.onSuccess { productDetail -> /* handle product */ }
.onFailure { error -> }
}Multiple offers on the same product (free trials, event discounts, winback offers): this applies to both subscriptions and one-time products. A subscription base plan — or a one-time product — can carry several concurrent purchase options in the Play Console: a default price, a free-trial offer, a time-limited "Christmas Sale" discount, a winback offer for lapsed subscribers, etc. Play only ever returns the ones the current user is eligible for. Each eligible offer shows up as its own ProductDetail entry sharing the same productId (and planId, for subscriptions) but a distinct offerId, so filter productsState's list by offerId to find promotional entries:
// Subscription promo offers on a given plan
val subsPromoOffers = productDetailList.filter { it.productId == "subs_id_1" && it.planId == "plan-id-1" && it.offerId.isNotEmpty() }
// One-time product discount offers (e.g. a Christmas sale on a non-consumable)
val inAppPromoOffers = productDetailList.filter { it.productId == "inapp_nonconsumable_1" && it.offerId.isNotEmpty() }For a discounted offer, pricingDetails reflects RecurringMode.DISCOUNTED (vs. ORIGINAL for the default price) so you can badge it in the UI the same way for one-time products as for subscription phases.
getProductDetail(productId, planId, offerId) — pass offerId = null (the default) for the product's default offer, or a specific offerId to target one particular promo. Same semantics apply to purchaseInApp/purchaseSubs/updateSubs below.
Retrieve comprehensive details of the item using the ProductDetail class:
@param productId: Unique ID (Console's ID) for product
@param planId: Unique ID (Console's ID) for plan
@param offerId: Distinguishes multiple concurrent offers on the same plan; empty = default offer
@param productTitle: e.g. Gold Tier
@param productType: e.g. InApp / Subs
@param pricingDetails: e.g. list of pricing containing price, currencyCode, planTitle, billingCycleCount. etc
- Weekly: P1W (One week)
- Every 4 weeks: P4W (Four weeks)
- Monthly: P1M (One month)
- Every 2 months (Bimonthly): P2M (Two months)
- Every 3 months (Quarterly): P3M (Three months)
- Every 4 months: P4M (Four months)
- Every 6 months (Semiannually): P6M (Six months)
- Every 8 months: P8M (Eight months)
- Yearly: P1Y (One year)
data class ProductDetail(
var productId: String,
var planId: String,
var offerId: String,
var productTitle: String,
var productType: ProductType,
var pricingDetails: List<PricingPhase>
)
@param recurringMode: e.g. FREE, DISCOUNTED, ORIGINAL
@param price: e.g. Rs 750.00
@param currencyCode: e.g. USD, PKR, etc
@param planTitle: e.g. Weekly, Monthly, Yearly, etc
@param billingCycleCount: e.g. 1, 2, 3, etc
@param billingPeriod: e.g. P1W, P1M, P1Y, etc
@param priceAmountMicros: e.g. 750000000
@param freeTrialPeriod: e.g. 3, 5, 7, etc
data class PricingPhase(
var recurringMode: RecurringMode,
var price: String,
var currencyCode: String,
var planTitle: String,
var billingCycleCount: Int,
var billingPeriod: String,
var priceAmountMicros: Long,
var freeTrialPeriod: Int,
)Each purchase call suspends until the purchase flow resolves and returns a PurchaseOutcome:
sealed interface PurchaseOutcome {
data class Success(val message: String) : PurchaseOutcome
data object AlreadyOwned : PurchaseOutcome
data object UserCancelled : PurchaseOutcome
data class Failed(val message: String, val responseCode: Int) : PurchaseOutcome
}After a Success/AlreadyOwned result, purchasesState has already been refreshed automatically — no manual re-fetch needed.
offerId is optional here too — omit it for the product's default price, or pass one to purchase a specific discount/promo offer (see Multiple offers on the same product above):
lifecycleScope.launch {
when (val outcome = billingManager.purchaseInApp(activity, "inapp_consumable_1", offerId = "christmas-sale-2026")) {
is PurchaseOutcome.Success -> Log.d("BillingTAG", "InApp Result: ${outcome.message}")
is PurchaseOutcome.AlreadyOwned -> { /* already owned */ }
is PurchaseOutcome.UserCancelled -> { /* user backed out */ }
is PurchaseOutcome.Failed -> Log.e("BillingTAG", "InApp Error: ${outcome.message}")
}
}offerId is optional — omit it (or pass null) to purchase the plan's default offer, or pass a specific offerId (e.g. one you picked from a promotional entry in productsState, see Step 3) to purchase that particular free-trial/discount/winback offer instead:
lifecycleScope.launch {
val outcome = billingManager.purchaseSubs(activity, "subs_id_1", "plan_id_1", offerId = "christmas-sale-2026")
Log.d("BillingTAG", "Subs Result: $outcome")
}lifecycleScope.launch {
val outcome = billingManager.updateSubs(
activity,
oldProductId = "subs_id_old",
productId = "subs_id_new",
planId = "plan_id_new",
offerId = null // or a specific offer id, same semantics as purchaseSubs()
)
Log.d("BillingTAG", "Subs Update: $outcome")
}billingManager.purchaseUpdates: SharedFlow<PurchaseOutcome> emits purchases that settle without a corresponding purchaseXxx() call in flight (e.g. a pending purchase completing after the app restarts):
lifecycleScope.launch {
billingManager.purchaseUpdates.collect { outcome -> /* handle late purchase updates */ }
}To add products and plans on the Play Console, consider using the following recommended subscription tags to generate plans.
Product ID: product_id_weekly
- Plan ID: plan-id-weekly
Product ID: product_id_monthly
- Plan ID: plan-id-monthly
Product ID: product_id_yearly
- Plan ID: plan-id-yearly
If you purchase a product and want to retrieve an old purchase from Google, it won't return the plan ID, making it impossible to identify which plan was purchased. To address this, you should save the purchase information on your server, including the product and plan IDs. This way, you can maintain a purchase list for future reference. Alternatively, you can use Option 1, where each product ID is associated with only one plan ID. This ensures that when you fetch a product ID, you can easily determine the corresponding plan that was purchased
For Gold Subscription
Product ID: gold_product
- Plan ID: gold-plan-weekly
- Plan ID: gold-plan-monthly
- Plan ID: gold-plan-yearly
and so on...
Fixed billing periods for subscriptions:
- Weekly
- Every 4 weeks
- Monthly
- Every 2 months (Bimonthly)
- Every 3 months (Quarterly)
- Every 4 months
- Every 6 months (Semiannually)
- Every 8 months
- Yearly
BillingManager should be a singleton for the app's lifetime (see Step 1) — do not construct it per-Activity/ViewModel and do not pass it a viewModelScope/lifecycleScope; the constructor doesn't accept one. It owns its billing connection and internal coroutine scope until you call billingManager.close(), which is typically never needed for an app-lifetime singleton.
Tip
Note: Use the BillingManager tag to observe the internal connection/purchase/mapping logs.
Version 4.0.0 removes the listener interfaces in favor of suspend funs, StateFlows, and automatic preloading. Package layout also changed:
com.hypersoft.billing.data.entities.* / presentation.enums.ProductType → com.hypersoft.billing.model.*.
| 3.x | 4.0.0 |
|---|---|
BillingManager(context, scope) |
BillingManager(context) — no external scope; create once as an app-lifetime singleton, call close() when truly done |
setListener(BillingConnectionListener).startConnection() |
connect(): ConnectionState (suspend) — auto-refreshes productsState/purchasesState on success; or observe connectionState: StateFlow<ConnectionState> |
fetchPurchaseHistory(BillingPurchaseHistoryListener) |
Collect purchasesState: StateFlow<UiState<List<PurchaseDetail>>> (auto-refreshed); refreshPurchases() to force a re-fetch |
fetchProductDetails(BillingProductDetailsListener) |
Collect productsState: StateFlow<UiState<List<ProductDetail>>> (auto-refreshed); refreshProducts() to force a re-fetch |
getProductDetail(id, planId, BillingProductDetailsListener) |
getProductDetail(id, planId, offerId = null): Result<ProductDetail> (suspend) |
purchaseInApp/purchaseSubs/updateSubs(..., BillingPurchaseListener) |
Same names, now suspend fun returning PurchaseOutcome; all three gained an optional offerId param for multi-offer products/plans |
| N/A | purchaseUpdates: SharedFlow<PurchaseOutcome> for purchases that settle outside a direct call |
| N/A | ProductDetail.offerId — distinguishes multiple concurrent offers (free trial, event discount, winback, ...) on the same product/plan, for both one-time products and subscriptions |
Copyright 2023 Hypersoft Inc
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.