Skip to main content

Google Play Billing Integration (Android)

Guida per integrare i 30 piani Visla GPS su Android con Billing Library 6.x.

πŸ“¦ Setup Google Play Console​

1. Creare i 30 Prodotti​

Google Play Console β†’ Monetization β†’ Subscriptions

Mensili (10 prodotti)​

Product IDNomePrezzo
monthly_11 Dispositivo - Mensile€4,90/mese
monthly_22 Dispositivi - Mensile€9,80/mese
monthly_33 Dispositivi - Mensile€11,70/mese
monthly_44 Dispositivi - Mensile€15,60/mese
monthly_55 Dispositivi - Mensile€19,50/mese
monthly_66 Dispositivi - Mensile€23,40/mese
monthly_77 Dispositivi - Mensile€27,30/mese
monthly_88 Dispositivi - Mensile€31,20/mese
monthly_99 Dispositivi - Mensile€35,10/mese
monthly_1010 Dispositivi - Mensile€39,00/mese

Semestrali (10 prodotti)​

Product IDNomePrezzo
semiannual_11 Dispositivo - 6 Mesi€23,50
semiannual_22 Dispositivi - 6 Mesi€47,00
.........
semiannual_1010 Dispositivi - 6 Mesi€187,00

Annuali (10 prodotti)​

Product IDNomePrezzo
annual_11 Dispositivo - Annuale€35,00
annual_22 Dispositivi - Annuale€70,00
.........
annual_1010 Dispositivi - Annuale€280,00

2. Configurare RTDN​

  1. Google Cloud β†’ Pub/Sub β†’ Topic: visla-subscriptions
  2. Push endpoint: https://api.vislagps.com/webhooks/google
  3. Play Console β†’ Monetization β†’ Real-time notifications β†’ Collega topic

πŸ“± Android Implementation​

Dependencies​

// app/build.gradle.kts
dependencies {
implementation("com.android.billingclient:billing-ktx:6.1.0")
}

Subscription Plans​

// billing/SubscriptionPlans.kt
object SubscriptionPlans {

enum class Period(val id: String) {
MONTHLY("monthly"),
SEMIANNUAL("semiannual"),
ANNUAL("annual")
}

val allProductIds: List<String> = buildList {
for (devices in 1..10) {
add("monthly_$devices")
add("semiannual_$devices")
add("annual_$devices")
}
}

fun productId(devices: Int, period: Period): String = "${period.id}_$devices"

fun extractDeviceCount(productId: String): Int {
return productId.split("_").lastOrNull()?.toIntOrNull() ?: 0
}
}

Billing Manager​

// billing/BillingManager.kt
package com.visla.gps.billing

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class BillingManager(private val context: Context) : PurchasesUpdatedListener {

private val _products = MutableStateFlow<List<ProductDetails>>(emptyList())
val products: StateFlow<List<ProductDetails>> = _products

private val _currentSubscription = MutableStateFlow<Purchase?>(null)
val currentSubscription: StateFlow<Purchase?> = _currentSubscription

private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady

private var userId: String = ""

private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases()
.build()

fun initialize(userId: String) {
this.userId = userId

billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_isReady.value = true
queryProducts()
queryPurchases()
}
}

override fun onBillingServiceDisconnected() {
_isReady.value = false
}
})
}

// MARK: - Query All 30 Products

private fun queryProducts() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
SubscriptionPlans.allProductIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
}
)
.build()

billingClient.queryProductDetailsAsync(params) { result, productDetailsList ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_products.value = productDetailsList.sortedBy {
SubscriptionPlans.extractDeviceCount(it.productId)
}
}
}
}

// MARK: - Get Products for Specific Device Count

fun productsForDevices(count: Int): List<ProductDetails> {
return _products.value.filter { it.productId.endsWith("_$count") }
}

fun getProduct(devices: Int, period: SubscriptionPlans.Period): ProductDetails? {
val productId = SubscriptionPlans.productId(devices, period)
return _products.value.find { it.productId == productId }
}

// MARK: - Purchase

fun launchPurchase(activity: Activity, productDetails: ProductDetails) {
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
?: return

val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
)
.setObfuscatedAccountId(userId) // ⚠️ IMPORTANTE!
.build()

billingClient.launchBillingFlow(activity, params)
}

// MARK: - Current Device Limit

fun currentDeviceLimit(): Int {
val purchase = _currentSubscription.value ?: return 0
val productId = purchase.products.firstOrNull() ?: return 0
return SubscriptionPlans.extractDeviceCount(productId)
}

// MARK: - Purchase Callback

override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged) {
acknowledgePurchase(purchase)
}
_currentSubscription.value = purchase
}
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> { }
else -> { }
}
}

private fun acknowledgePurchase(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()

billingClient.acknowledgePurchase(params) { }
}

private fun queryPurchases() {
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()

billingClient.queryPurchasesAsync(params) { result, purchaseList ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_currentSubscription.value = purchaseList.firstOrNull {
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
purchaseList.filter { !it.isAcknowledged }.forEach { acknowledgePurchase(it) }
}
}
}

fun destroy() {
billingClient.endConnection()
}
}

Subscription Screen (Compose)​

// ui/SubscriptionScreen.kt
@Composable
fun SubscriptionScreen(
billingManager: BillingManager,
onPurchase: (ProductDetails) -> Unit
) {
var selectedDevices by remember { mutableIntStateOf(3) }
var selectedPeriod by remember { mutableStateOf(SubscriptionPlans.Period.ANNUAL) }

val products by billingManager.products.collectAsState()
val isReady by billingManager.isReady.collectAsState()

val selectedProduct = billingManager.getProduct(selectedDevices, selectedPeriod)

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Scegli il tuo Piano",
style = MaterialTheme.typography.headlineMedium
)

Spacer(modifier = Modifier.height(24.dp))

// Device Selector
Text("Quanti dispositivi?", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))

LazyVerticalGrid(
columns = GridCells.Fixed(5),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(10) { index ->
val n = index + 1
Button(
onClick = { selectedDevices = n },
colors = ButtonDefaults.buttonColors(
containerColor = if (selectedDevices == n)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Text("$n")
}
}
}

Spacer(modifier = Modifier.height(24.dp))

// Period Selector
PeriodSelector(
selectedPeriod = selectedPeriod,
devices = selectedDevices,
products = products,
onSelect = { selectedPeriod = it }
)

Spacer(modifier = Modifier.weight(1f))

// Subscribe Button
selectedProduct?.let { product ->
val price = product.subscriptionOfferDetails?.firstOrNull()
?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: ""

Button(
onClick = { onPurchase(product) },
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp)
) {
Text("Abbonati - $price")
}
}
}
}

@Composable
fun PeriodSelector(
selectedPeriod: SubscriptionPlans.Period,
devices: Int,
products: List<ProductDetails>,
onSelect: (SubscriptionPlans.Period) -> Unit
) {
fun priceFor(period: SubscriptionPlans.Period): String {
val productId = SubscriptionPlans.productId(devices, period)
val product = products.find { it.productId == productId }
return product?.subscriptionOfferDetails?.firstOrNull()
?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "-"
}

Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PeriodOption(
title = "Mensile",
price = priceFor(SubscriptionPlans.Period.MONTHLY),
isSelected = selectedPeriod == SubscriptionPlans.Period.MONTHLY,
onClick = { onSelect(SubscriptionPlans.Period.MONTHLY) }
)
PeriodOption(
title = "Semestrale",
price = priceFor(SubscriptionPlans.Period.SEMIANNUAL),
badge = "-20%",
isSelected = selectedPeriod == SubscriptionPlans.Period.SEMIANNUAL,
onClick = { onSelect(SubscriptionPlans.Period.SEMIANNUAL) }
)
PeriodOption(
title = "Annuale",
price = priceFor(SubscriptionPlans.Period.ANNUAL),
badge = "-40%",
isSelected = selectedPeriod == SubscriptionPlans.Period.ANNUAL,
onClick = { onSelect(SubscriptionPlans.Period.ANNUAL) }
)
}
}

πŸ”‘ Punti Critici​

  1. obfuscatedAccountId: Passa sempre l'user_id
  2. acknowledgePurchase: Obbligatorio entro 3 giorni
  3. 30 prodotti: Creali tutti in Play Console

βœ… Checklist​

  • Crea 30 subscriptions in Play Console
  • Configura RTDN con Pub/Sub
  • Implementa BillingManager con tutti i product IDs
  • Implementa UI selezione dispositivi + periodo
  • Testa con License Testing
  • Acknowledge tutte le purchase