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

Compras no aplicativo para Android, parte 2: processamento de compras com a Biblioteca Google Play de Faturamento.

Vlad Guriev

Updated: March 20, 2023

Content

62fdf1c4519df62803e8fafe jp android tutorial 1 configuration 2

No artigo anterior, criamos uma classe de wrapper para trabalhar com a Biblioteca de Faturamento:

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
   }
}

Vamos prosseguir com a implementação da compra e melhorar nossa classe.

Como criar uma tela de assinatura

Qualquer aplicativo que apresente compras no aplicativo tem uma tela de paywall. Existem políticas do Google que definem o número mínimo de elementos e textos instrucionais que devem estar presentes nas telas. Segue um resumo. Na tela do paywall, é necessário ser explícito sobre as condições de assinatura, custos e duração, assim como especificar se a assinatura é necessária para utilizar o aplicativo (app). Deve-se também evitar forçar seus usuários a realizar qualquer ação adicional para rever as condições. 

Vamos usar como exemplo uma tela de paywall simplificada:

631abc67c79cf6614ff8de49 3321
Exemplo de paywall no Android

‍Temos os seguintes elementos na tela do paywall:

  • Um cabeçalho.
  • Botões definidos para iniciar o processo de compra. Estes botões orientam o usuário sobre os detalhes gerais das opções de assinatura, como o título e os custos, que são exibidas na moeda local.
  • Texto de instrução. 
  • Um botão para restaurar a compra feita anteriormente. Este elemento é obrigatório para qualquer aplicativo que apresente assinaturas ou compras não consumíveis.

Ajuste do código para exibir as informações do produto

Há quatro produtos no nosso exemplo:

  • Duas assinaturas com renovação automática (“premium_sub_month” e “premium_sub_year”);
  • Um produto que não pode ser comprado duas vezes ou um produto não consumível (“unlock_feature”);
  • Um produto que pode ser comprado várias vezes ou um produto consumível (“coin_pack_large”).

Para simplificar o exemplo, usaremos uma Atividade que iremos injetar com o BillingClientWrapper criado no artigo anterior, assim como um layout com um número fixo de botões de compra.

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
   }
}

Por comodidade, vamos adicionar um mapa onde a chave é o sku do produto, e o valor é o botão correspondente na tela.

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,
   )
}

Vamos declarar um método para exibir os produtos na IU. Ele será baseado na lógica introduzida em nosso tutorial anterior.

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 é uma sequência já formatada que especifica a moeda local para a conta. não precisa de nenhuma formatação adicional. Todas as outras propriedades do objeto da classe SkuDetails também são totalmente localizadas.

Como iniciar o processo de compra

Para processar a compra, temos que usar o método launchBillingFlow() do thread principal do aplicativo.

Para isso, vamos adicionar um método de compra() ao BillingClientWrapper. 

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

O método launchBillingFlow() não tem retorno de chamada. A resposta retornará ao método onPurchasesUpdated(). Você se lembra que citamos isso no artigo anterior e pedimos guardar a informação para mais tarde? Bem, vamos precisar dela agora.

O método onPurchasesUpdated() é chamado sempre que houver algum resultado fora da interação do usuário com o diálogo de compra. Pode ser uma compra realizada com sucesso ou um cancelamento de compra causado pelo fechamento da caixa de diálogo por parte do usuário, neste caso obteremos o código BillingResponseCode.USER_CANCELED. Outra possibilidade pode ser qualquer uma das outras mensagens de erro possíveis.

De forma semelhante à interface OnQueryProductsListener mencionada no artigo anterior, vamos citar uma interface OnPurchaseListener na classe BillingClientWrapper. Através dessa interface, receberemos a compra (um objeto da classe Compra ou uma mensagem de erro declarada por nós no guia anterior. No próximo artigo, discutiremos o caso em que o objeto da classe “Compra” pode ser nulo, mesmo que a compra tenha sido realizada com sucesso.

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

var onPurchaseListener: OnPurchaseListener? = null

A seguir, vamos implementá-la no 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!

Vamos adicionar alguma lógica ao 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
               )
           )
       }
   }
}

Caso a purchaseList não esteja vazia, primeiro teremos que escolher os itens cuja purchaseState é igual a PurchaseState.PURCHASED, á que também existem compras pendentes. Se for esse o caso, o fluxo de usuários termina aqui. De acordo com os documentos, devemos verificar a compra no nosso servidor. Abordaremos este assunto nos próximos artigos desta série. Assim que for concluída a verificação do servidor, você deve entregar o conteúdo e informar o Google. Caso isso não seja feito, a compra será automaticamente reembolsada em três dias. É bastante interessante que esta política seja exclusiva do Google Play. O iOS não impõe nenhuma política semelhante. Você tem duas maneiras de reconhecer a entrega de seu conteúdo ao usuário:

Quando se trata de um produto consumível, temos que usar o método consumeAsync(). Ele reconhece a compra em detalhes, ao mesmo tempo em que torna possível refazer a compra do produto. Isto só pode ser feito com a Biblioteca de Faturamento. Por alguma razão, a API do Google Play Developer não oferece nenhuma maneira de fazer isso no sistema backend. É bastante curioso que, ao contrário do Google Play, o App Store e o AppGallery configurem o produto como consumível via App Store Connect e AppGallery Connect, respectivamente. No entanto, tais produtos no AppGallery também devem ser consumidos de forma explícita.

Vamos escrever os métodos de reconhecimento e consumo, assim como duas versões do método ProcessPurchase() para contabilizar se estamos ou não executando nosso próprio sistema 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)
       }
   }
}

Sem verificação do servidor:

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()
               }
           }
       }
   }
}

Com verificação do servidor:

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
           }
       }
   }
}

Nos próximos artigos, vamos tratar da verificação do servidor para compras de forma mais detalhada.

Naturalmente, podemos também implementar o reconhecimento no segundo exemplo do lado do cliente. No entanto, caso você possa fazer algo no sistema backend, faça. Caso o reconhecimento ou o processo de consumo gere algum erro, ele não poderá ser ignorado. Caso nenhum deles seja executado no prazo de 3 dias após a compra ter recebido o status PurchaseState.PURCHASED, ele será cancelado e reembolsado. Portanto, se for impossível fazer no sistema backend e você continuar recebendo o erro após uma série de tentativas, existe uma solução confiável. Você vai ter que obter as compras atuais do usuário através de algum método de ciclo de vida, como onStart() ou onResume(), e continuar tentando, esperando que o usuário abra o aplicativo no prazo de 3 dias durante sua conexão com a Internet. 🙂

Portanto, a versão atual da classe BillingClientWrapper será assim:

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)
           }
       }
   }
}

Talvez você pergunte por que os botões estão ativos para todos os produtos, não importando se o usuário já os comprou ou não. Ou talvez você tenha algumas perguntas sobre o que acontece se você comprar as duas assinaturas. A primeira assinatura substituirá a segunda, ou elas vão coexistir? Confira os próximos artigos para obter todas as respostas 🙂