Report: State of in-app subscriptions in the US 2023 Get a report

Acquisti in-app per Android, parte 2: elaborazione degli acquisti con la Libreria Fatturazione Google Play

Vlad Guriev

Updated: Marzo 20, 2023

Content

62fdf1c4519df62803e8fafe jp android tutorial 1 configuration 2

Nell’articolo precedente, abbiamo creato una classe wrapper per lavorare con la Libreria Fatturazione:

import android.content.Context
import com.android.billingclient.api.*
class BillingClientWrapper(context: Context) : PurchasesUpdatedListener {
   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }
   class Error(val responseCode: Int, val debugMessage: String)
   private val billingClient = BillingClient
       .newBuilder(context)
       .enablePendingPurchases()
       .setListener(this)
       .build()
   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp")
       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }
   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }
   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }
           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }
   override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) {
       // here come callbacks about new purchases
   }
}

Procediamo all’implementazione dell’acquisto (purchase implementation) e miglioriamo la nostra classe.

Progettazione di una schermata per l’abbonamento

Tutte le app che prevedono acquisti in-app hanno una schermata di paywall. Sono in atto policy Google che definiscono il minimo indispensabile di elementi e testi di istruzioni che devono essere presenti in queste schermate. Di seguito, ne lasciamo un riepilogo. Nella schermata del paywall, è necessario indicare esplicitamente le condizioni di abbonamento, il costo e la durata, oltre che specificare se l’abbonamento sia necessario per utilizzare l’app. È inoltre necessario evitare di costringere gli utenti a eseguire ulteriori azioni per riesaminare le condizioni. 

A titolo di esempio, utilizzeremo una schermata semplificata di paywall:

android sample paywall
Esempio di paywall per Android

Sulla schermata del paywall sono presenti i seguenti elementi:

  • L’intestazione.
  • I pulsanti impostati per avviare il processo d’acquisto. Questi pulsanti guidano l’utente sui dettagli generali delle opzioni di abbonamento, come il titolo e il costo, che viene visualizzato nella valuta locale.
  • I testi di istruzioni. 
  • Un pulsante per ripristinare l’acquisto precedentemente effettuato. Questo elemento è indispensabile per tutte le app che prevedono abbonamenti o acquisti non consumabili.

Come adattare il codice per visualizzare i dettagli del prodotto

Nel nostro esempio, prendiamo in considerazione due prodotti:

  • Due abbonamenti con rinnovamento automatico (auto-renewable subscription) (“premium_sub_month” e “premium_sub_year”);
  • Un prodotto che non può essere acquistato due volte o un prodotto non consumabile (“unlock_feature”);
  • Un prodotto che può essere acquistato più volte o un prodotto consumabile (“coin_pack_large”).

Per semplificare l’esempio, utilizzeremo un’attività in cui inseriremo il BillingClientWrapper creato nell’articolo precedente e un layout con un numero fisso di pulsanti di acquisto.

class PaywallActivity: BaseActivity() {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       displayProducts() //to be declared below
   }
}

Per comodità, aggiungeremo una mappa in cui la chiave è il nome del prodotto sku e il valore è il pulsante corrispondente sulla schermata.

private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
   mapOf(
       "premium_sub_month" to monthlySubButton,
       "premium_sub_year" to yearlySubButton,
       "coin_pack_large" to coinPackLargeButton,
       "unlock_feature" to unlockFeatureButton,
   )
}

Dichiariamo un metodo per visualizzare i prodotti nell’interfaccia utente. Questo sarà basato sulla logica presentata nel nostro precedente tutorial.

private fun displayProducts() {
   billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
       override fun onSuccess(products: List<SkuDetails>>) {
           products.forEach { product ->
               purchaseButtonsMap[product.sku]?.apply {
                   text = "${product.description} for ${product.price}"
                   setOnClickListener {
                       billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below
                   }
               }
           }
       }

       override fun onFailure(error: BillingClientWrapper.Error) {
           //handle error
       }
   })
}

product.price è una stringa già formattata che specifica la valuta locale dell’account. Non sono necessarie altre formattazioni. Anche tutte le altre proprietà dell’oggetto della classe SkuDetails sono già completamente localizzate.

Avvio del processo d’acquisto

Per elaborare l’acquisto, dobbiamo richiamare il metodo launchBillingFlow() dal thread principale dell’app.

A tal fine, aggiungeremo un metodo purchase() al BillingClientWrapper. 

fun purchase(activity: Activity, product: SkuDetails) {
   onConnected {
       activity.runOnUiThread {
           billingClient.launchBillingFlow(
               activity,
               BillingFlowParams.newBuilder().setSkuDetails(product).build()
           )
       }
   }
}

Il metodo launchBillingFlow() non ha un callback. La risposta viene restituita al metodo onPurchasesUpdated(). Ricordi che lo abbiamo dichiarato nell’articolo precedente e che lo abbiamo salvato un momento successivo? Bene, ora è il momento di usarlo.

Il metodo onPurchasesUpdated() viene chiamato ogni volta che l’interazione dell’utente con la finestra di dialogo degli acquisti produce un risultato. Può trattarsi di un acquisto andato a buon fine o di un annullamento dell’acquisto causato dalla chiusura della finestra di dialogo da parte dell’utente, nel qual caso si otterrà l’opzione BillingResponseCode.con codice USER_CANCELED. In alternativa, potremmo avere uno qualsiasi degli altri messaggi di errore possibili.

In modo simile all’interfaccia OnQueryProductsListener dell’articolo precedente, dichiareremo un’interfaccia OnPurchaseListener nella classe BillingClientWrapper. Tramite questa interfaccia, riceveremo l’acquisto (un oggetto della classe Purchase) o un messaggio di errore dichiarato da noi nella guida precedente. Nel prossimo capitolo, discuteremo il caso in cui l’oggetto di classe Purchase può essere nullo anche se l’acquisto è andato a buon fine.

interface OnPurchaseListener {
   fun onPurchaseSuccess(purchase: Purchase?)
   fun onPurchaseFailure(error: Error)
}

var onPurchaseListener: OnPurchaseListener? = null

Successivamente, lo implementeremo nell’attività PaywallActivity:

class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener {

   @Inject
   lateinit var billingClientWrapper: BillingClientWrapper

   private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) {
       mapOf(
           "premium_sub_month" to monthlySubButton,
           "premium_sub_year" to yearlySubButton,
           "coin_pack_large" to coinPackLargeButton,
           "unlock_feature" to unlockFeatureButton,
       )
   }

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_paywall)

       billingClientWrapper.onPurchaseListener = this

       displayProducts()
   }

   override fun onPurchaseSuccess(purchase: Purchase?) {
       //handle successful purchase
   }

   override fun onPurchaseFailure(error: BillingClientWrapper.Error) {
       //handle error or user cancelation
   }

   private fun displayProducts() {
       billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener {
           override fun onSuccess(products: List<SkuDetails>) {
               products.forEach { product ->
                   purchaseButtonsMap[product.sku]?.apply {
                       text = "${product.description} for ${product.price}"
                       setOnClickListener {
                           billingClientWrapper.purchase(this@PaywallActivity, product)
                       }
                   }
               }
           }

           override fun onFailure(error: BillingClientWrapper.Error) {
               //handle error
           }
       })
   }
}

Subscribe to Adapty newsletter

Get fresh paywall ideas, subscription insights, and mobile app news every month!

Aggiungiamo la logica a onPurchaseUpdated():

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchaseList: MutableList<Purchase>?
) {
   when (billingResult.responseCode) {
       BillingClient.BillingResponseCode.OK -> {
           if (purchaseList == null) {
               //to be discussed in the next article
               onPurchaseListener?.onPurchaseSuccess(null)
               return
           }

           purchaseList.forEach(::processPurchase) //to be declared below
       }
       else -> {
           //error occured or user canceled
           onPurchaseListener?.onPurchaseFailure(
               BillingClientWrapper.Error(
                   billingResult.responseCode,
                   billingResult.debugMessage
               )
           )
       }
   }
}

Se purchaseList non è vuoto, dobbiamo prima selezionare gli elementi il cui purchaseState è uguale a PurchaseState.PURCHASED, poiché ci sono anche acquisti in sospeso. In questo caso, il flusso dell’utente termina qui. In base alla documentazione, ora dovremmo verificare l’acquisto sul nostro server. Di questo, parleremo negli articoli successivi di questa serie. Una volta completata la verifica del server, è necessario consegnare il contenuto e comunicarlo a Google. In mancanza di quest’ultimo, l’acquisto verrà automaticamente rimborsato entro tre giorni. È interessante notare che questa policy è esclusiva di Google Play: iOS non ne impone di simili. Hai due modi per confermare la consegna del vostro contenuto all’utente:

Se si tratta di un prodotto consumabile, invece, dobbiamo richiamare il metodo consumeAsync(). Questo conferma l’acquisto sottostante, rendendo al contempo possibile un nuovo acquisto del prodotto. Ciò è possibile solo con la Libreria Fatturazione. Per qualche motivo, l’API per gli sviluppatori di Google Play non offre alcun modo per farlo sul lato backend. È piuttosto strano che, a differenza di Google Play, sia App Store che AppGallery configurino il prodotto come consumabile rispettivamente tramite App Store Connect e AppGallery Connect. Tuttavia, tali prodotti di AppGallery dovrebbero essere consumati anche in modo esplicito.

Scriviamo i metodi per confermare e consumare, oltre a due versioni del metodo processPurchase() per tenere conto del fatto che stiamo eseguendo o meno il nostro backend.

private fun acknowledgePurchase(
   purchase: Purchase,
   callback: AcknowledgePurchaseResponseListener
) {
   onConnected {
       billingClient.acknowledgePurchase(
           AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
               .build(),
           callback::onAcknowledgePurchaseResponse
       )
   }
}

private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
   onConnected {
       billingClient.consumeAsync(
           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
       ) { billingResult, purchaseToken ->
           callback.onConsumeResponse(billingResult, purchaseToken)
       }
   }
}

Senza verifica del server:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       onPurchaseListener?.onPurchaseSuccess(purchase)

       if (purchase.skus.firstOrNull() == "coin_pack_large") {
           //consuming our only consumable product
           consumePurchase(purchase) { billingResult, purchaseToken ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to consume again in onResume()
               }
           }
       } else if (!purchase.isAcknowledged) {
           acknowledgePurchase(purchase) { billingResult ->
               if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                   //implement retry logic or try to acknowledge again in onResume()
               }
           }
       }
   }
}

Con verifica del server:

private fun processPurchase(purchase: Purchase) {
   if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
       api.verifyPurchase(purchase.purchaseToken) { error ->
           if (error != null) {
               onPurchaseListener?.onPurchaseSuccess(purchase)

               if (purchase.skus.firstOrNull() == "coin_pack_large") {
                   //consuming our only consumable product
                   billingClient.consumeAsync(
                       ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                           .build()
                   ) { billingResult, purchaseToken ->
                       if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                           //implement retry logic or try to consume again in onResume()
                       }
                   }
               }
           } else {
               //handle verification error
           }
       }
   }
}

Negli articoli che seguiranno, tratteremo in modo più dettagliato la verifica del server per gli acquisti.

Naturalmente, potremmo anche implementare la conferma del secondo esempio sul lato client. Tuttavia, se si può fare qualcosa sul backend, allora si dovrebbe farlo. Se il processo di conferma o di consumo presentano degli errori, non sarà possibile ignorarli. Nel caso in cui nessuno di questi venga eseguito entro tre giorni dall’acquisto con la ricezione dello stato PurchaseState.ACQUISTATO , sarà annullato e rimborsato. Quindi, se è impossibile farlo sul backend e si continua a ricevere l’errore dopo un certo numero di tentativi, esiste comunque una soluzione affidabile. Si dovranno ottenere gli acquisti correnti dell’utente tramite un metodo del ciclo di vita, come ad esempio onStart() o onResume() e continuare a provare, sperando che l’utente apra l’app entro tre giorni quando la sua connessione a Internet è attiva.:)

Per cui, la versione attuale della classe BillingClientWrapper verrà visualizzata nel modo seguente:

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.*

class BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener {

   interface OnQueryProductsListener {
       fun onSuccess(products: List<SkuDetails>)
       fun onFailure(error: Error)
   }

   interface OnPurchaseListener {
       fun onPurchaseSuccess(purchase: Purchase?)
       fun onPurchaseFailure(error: Error)
   }

   var onPurchaseListener: OnPurchaseListener? = null

   class Error(val responseCode: Int, val debugMessage: String)

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

   fun purchase(activity: Activity, product: SkuDetails) {
       onConnected {
           activity.runOnUiThread {
               billingClient.launchBillingFlow(
                   activity,
                   BillingFlowParams.newBuilder().setSkuDetails(product).build()
               )
           }
       }
   }

   fun queryProducts(listener: OnQueryProductsListener) {
       val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature")

       queryProductsForType(
           skusList,
           BillingClient.SkuType.SUBS
       ) { billingResult, skuDetailsList ->
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               val products = skuDetailsList ?: mutableListOf()
               queryProductsForType(
                   skusList,
                   BillingClient.SkuType.INAPP
               ) { billingResult, skuDetailsList ->
                   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                       products.addAll(skuDetailsList ?: listOf())
                       listener.onSuccess(products)
                   } else {
                       listener.onFailure(
                           Error(billingResult.responseCode, billingResult.debugMessage)
                       )
                   }
               }
           } else {
               listener.onFailure(
                   Error(billingResult.responseCode, billingResult.debugMessage)
               )
           }
       }
   }

   private fun queryProductsForType(
       skusList: List<String>,
       @BillingClient.SkuType type: String,
       listener: SkuDetailsResponseListener
   ) {
       onConnected {
           billingClient.querySkuDetailsAsync(
               SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(),
               listener
           )
       }
   }

   private fun onConnected(block: () -> Unit) {
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingSetupFinished(billingResult: BillingResult) {
               block()
           }

           override fun onBillingServiceDisconnected() {
               // Try to restart the connection on the next request to
               // Google Play by calling the startConnection() method.
           }
       })
   }

   override fun onPurchasesUpdated(
       billingResult: BillingResult,
       purchaseList: MutableList<Purchase>?
   ) {
       when (billingResult.responseCode) {
           BillingClient.BillingResponseCode.OK -> {
               if (purchaseList == null) {
                   //to be discussed in the next article
                   onPurchaseListener?.onPurchaseSuccess(null)
                   return
               }

               purchaseList.forEach(::processPurchase)
           }
           else -> {
               //error occured or user canceled
               onPurchaseListener?.onPurchaseFailure(
                   BillingClientWrapper.Error(
                       billingResult.responseCode,
                       billingResult.debugMessage
                   )
               )
           }
       }
   }

   private fun processPurchase(purchase: Purchase) {
       if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
           api.verifyPurchase(purchase.purchaseToken) { error ->
               if (error != null) {
                   onPurchaseListener?.onPurchaseSuccess(purchase)

                   if (purchase.skus.firstOrNull() == "coin_pack_large") {
                       //consuming our only consumable product
                       billingClient.consumeAsync(
                           ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                               .build()
                       ) { billingResult, purchaseToken ->
                           if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                               //implement retry logic or try to consume again in onResume()
                           }
                       }
                   }
               } else {
                   //handle verification error
               }
           }
       }
   }

   private fun acknowledgePurchase(
       purchase: Purchase,
       callback: AcknowledgePurchaseResponseListener
   ) {
       onConnected {
           billingClient.acknowledgePurchase(
               AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken)
                   .build(),
               callback::onAcknowledgePurchaseResponse
           )
       }
   }

   private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) {
       onConnected {
           billingClient.consumeAsync(
               ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build()
           ) { billingResult, purchaseToken ->
               callback.onConsumeResponse(billingResult, purchaseToken)
           }
       }
   }
}

Potresti chiederti come mai i pulsanti siano attivi per tutti i prodotti, indipendentemente dal fatto che l’utente li abbia già acquistati o meno. Oppure, potresti avere delle domande su cosa succede se acquisti entrambi gli abbonamenti. Il primo abbonamento sostituirà il secondo o coesisteranno? Per avere tutte le risposte, leggi i prossimi articoli 🙂