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 ID | Nome | Prezzo |
|---|---|---|
monthly_1 | 1 Dispositivo - Mensile | β¬4,90/mese |
monthly_2 | 2 Dispositivi - Mensile | β¬9,80/mese |
monthly_3 | 3 Dispositivi - Mensile | β¬11,70/mese |
monthly_4 | 4 Dispositivi - Mensile | β¬15,60/mese |
monthly_5 | 5 Dispositivi - Mensile | β¬19,50/mese |
monthly_6 | 6 Dispositivi - Mensile | β¬23,40/mese |
monthly_7 | 7 Dispositivi - Mensile | β¬27,30/mese |
monthly_8 | 8 Dispositivi - Mensile | β¬31,20/mese |
monthly_9 | 9 Dispositivi - Mensile | β¬35,10/mese |
monthly_10 | 10 Dispositivi - Mensile | β¬39,00/mese |
Semestrali (10 prodotti)β
| Product ID | Nome | Prezzo |
|---|---|---|
semiannual_1 | 1 Dispositivo - 6 Mesi | β¬23,50 |
semiannual_2 | 2 Dispositivi - 6 Mesi | β¬47,00 |
| ... | ... | ... |
semiannual_10 | 10 Dispositivi - 6 Mesi | β¬187,00 |
Annuali (10 prodotti)β
| Product ID | Nome | Prezzo |
|---|---|---|
annual_1 | 1 Dispositivo - Annuale | β¬35,00 |
annual_2 | 2 Dispositivi - Annuale | β¬70,00 |
| ... | ... | ... |
annual_10 | 10 Dispositivi - Annuale | β¬280,00 |
2. Configurare RTDNβ
- Google Cloud β Pub/Sub β Topic:
visla-subscriptions - Push endpoint:
https://api.vislagps.com/webhooks/google - 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β
- obfuscatedAccountId: Passa sempre l'user_id
- acknowledgePurchase: Obbligatorio entro 3 giorni
- 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