BlogRight arrowTutorialRight ArrowZakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library
BlogRight arrowTutorialRight ArrowZakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library

Zakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library

Zakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library
Listen to the episode
Zakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library

W poprzednim artykule stworzyliśmy klasę wrapper do pracy z Billing Library:

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

Przejdźmy do realizacji zakupu i udoskonalmy naszą klasę.

⭐️ Download our guide on in-app techniques which will make in-app purchases in your app perfect

Projektowanie ekranu subskrypcji

Każda aplikacja, która oferuje zakupy w aplikacji, posiada ekran paywall. Istnieją pewne Polityki Google, które definiują minimum elementów i tekstów instruktażowych, które muszą być obecne na takich ekranach. Oto ich podsumowanie. Na ekranie paywall musisz jasno określić warunki, koszt i czas trwania subskrypcji, a także określić, czy subskrypcja jest konieczna, aby możliwe było korzystanie z aplikacji. Należy również unikać zmuszania użytkowników do wykonywania jakichkolwiek dodatkowych działań w celu sprawdzenia warunków. 

Jako przykład posłużymy się uproszczonym ekranem paywall:

Przykładowy paywall dla Androida

Na ekranie paywall obecne są następujące elementy:

  • Nagłówek.
  • Przyciski, ustawione tak, aby rozpoczynały proces zakupu. Przyciski te informują użytkownika o ogólnych szczegółach opcji subskrypcji, takich jak tytuł i cena, która jest wyświetlana w lokalnej walucie.
  • Tekst - instrukcja. 
  • Przycisk przywracający wcześniej dokonany zakup. Ten element jest obowiązkowy dla każdej aplikacji, która zawiera subskrypcje lub zakupy niezwiązane z jednorazowym użyciem nabytego dobra.

Modyfikowanie kodu w celu wyświetlania szczegółów produktu

W naszym przykładzie są cztery produkty:

  • Dwie automatycznie odnawialne subskrypcje ("premium_sub_month "i"premium_sub_year");
  • Produkt, którego nie można kupić dwa razy, lub niezwiązany z jednorazowym użyciem nabytego dobra (“unlock_feature“);
  • Produkt, który można kupić wielokrotnie, lub produkt konsumpcyjny ("coin_pack_large").

Aby uprościć przykład, użyjemy działania, które wstrzykniemy za pomocą BillingClientWrapper utworzonego w poprzednim artykule, a także układu ze stałą liczbą przycisków zakupu.

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

Dla wygody dodamy mapę, gdzie kluczem jest sku produktu, a wartością jest odpowiedni przycisk na ekranie.

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

Zadeklarujemy metodę wyświetlania produktów w interfejsie użytkownika. Będzie on oparty na logice opisanej w naszym poprzednim 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  jest wcześniej sformatowanym ciągiem znaków, który określa lokalną walutę dla konta. Nie wymaga on dodatkowego formatowania. Wszystkie inne właściwości klasy obiektu SkuDetails są również w pełni zlokalizowane.

Rozpoczęcie procesu zakupu

Aby przetworzyć zakup, musimy powołać się na metodę launchBillingFlow() z głównego wątku aplikacji.

W tym celu dodamy metodę purchase() do BillingClientWrapper. 

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

Metoda launchBillingFlow () nie posiada funkcji zwrotnej. Odpowiedź powróci do metody onPurchasesUpdated (). Czy pamiętasz, jak deklarowaliśmy ją w poprzednim artykułe, a następnie zostawiliśmy to sobie na później? Cóż, będziemy tego potrzebować teraz.

Metoda onPurchasesUpdated() jest wywoływana za każdym razem, gdy pojawia się jakiś wynik z interakcji użytkownika z oknem zakupowym. Może to być udany zakup lub anulowanie zakupu, spowodowane zamknięciem okna dialogowego przez użytkownika, w takim przypadku otrzymamy kod BillingResponseCode.USER_CANCELED. Alternatywnie, może to być jakikolwiek z możliwych komunikatów o błędach.

W podobny sposób jak interfejs OnQueryProductsListener z poprzedniego artykułu, zadeklarujemy interfejs OnPurchaseListener w klasie BillingClientWrapper. Poprzez ten interfejs otrzymamy albo zakup (a obiekt klasy Purchase) lub komunikat o błędzie zadeklarowany przez nas w poprzednim poradniku. W następnym omówimy przypadek, w którym obiekt klasy Purchase może mieć wartość null, nawet jeśli zakup się powiódł.

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

var onPurchaseListener: OnPurchaseListener? = null

Następnie zaimplementujemy go w 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
           }
       })
   }
}

Dodajmy trochę logiki do 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
               )
           )
       }
   }
}

Jeśli purchaseList nie jest puste, musimy najpierw wybrać przedmioty, dla których purchaseState jest równe PurchaseState.PURCHASED, ponieważ istnieją także zakupy oczekujące. W takim przypadku przepływ użytkownika kończy się w tym miejscu. Według docs, powinniśmy następnie zweryfikować zakup na naszym serwerze. Omówimy to w kolejnych artykułach w tej serii. Po zakończeniu weryfikacji serwera musisz dostarczyć zawartość i poinformować o tym Google. Bez tego ostatniego, zakup zostanie automatycznie zwrócony w ciągu trzech dni. To dość ciekawe, że ta polityka jest unikalna dla Google Play — iOS nie narzuca niczego podobnego. Masz dwa sposoby potwierdzania dostarczenia treści użytkownikowi:

Jeśli mamy do czynienia z produktem konsumpcyjnym, musimy powołać się zamiast tego na metodę consumeAsync(). Potwierdza to zakup pod maską, jednocześnie umożliwiając ponowny zakup produktu. Można to zrobić tylko w Billing Library. Z jakiegoś powodu Google Play Developer API nie oferuje żadnego sposobu, aby to zrobić na stronie backendu. To dość ciekawe, że w przeciwieństwie do Google Play, zarówno App Store, jak i AppGallery konfigurują produkt jako konsumpcyjny za poprzez, odpowiednio, App Store Connect i AppGallery Connect. Jednak takie produkty AppGallery powinny również zostać skonsumowane w sposób wyraźny.

Start for free

Convenient in-app purchases infrastructure.

Adapty SDK has it all:
— server-side purchase validation,
— all side cases covered,
— simple implementation.

Start for free

Napiszmy metody dla potwierdzenia i konsumpcji, a także dwie wersje metody processPurchase(), aby wyjaśnić, czy uruchamiamy nasz własny 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)
       }
   }
}

Bez weryfikacji serwera:

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

Z weryfikacją serwera:

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

W kolejnych artykułach omówimy bardziej szczegółowo weryfikację serwera dla zakupów.

Oczywiście możemy również zaimplementować potwierdzenie w drugim przykładzie po stronie klienta. Jeśli jednak możesz coś zrobić na backendzie, to lepiej zastosować tę metodę. Jeśli zarówno proces potwierdzenia, jak i proces konsumpcji generują błędy, nie można ich zignorować. W przypadku, gdy żaden z nich nie zostanie zrealizowany w ciągu 3 dni od otrzymania statusu zakupu PurchaseState.PURCHASED, zostanie on anulowany i zwrócony. Jeśli więc nie da się tego wykonać na backendzie i nadal otrzymujesz błąd pomimo wielu prób, istnieje niezawodne obejście. Będzie konieczne uzyskanie bieżących zakupów użytkownika za pomocą pewnej metody lifecycle, takiej jak OnStart () lub onResume() i próbując cały czas, mając nadzieję, że użytkownik otworzy aplikację w ciągu 3 dni z włączonym Internetem. :)

Dlatego aktualna wersja klasy BillingClientWrapper będzie wyglądać następująco:

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

Możesz zapytać, dlaczego przyciski są aktywne dla wszystkich produktów, bez względu na to, czy użytkownik już je kupił, czy nie. Możesz też mieć pytania dotyczące tego, co się stanie, jeśli ktoś kupi obie subskrypcje. Czy pierwsza subskrypcja zastąpi drugą, czy też będzie współistnieć? Przeczytaj kolejne artykuły, aby uzyskać wszystkie odpowiedzi :)

Further reading

How to choose a category to launch an app
How to choose a category to launch an app
January 27, 2022
44 min listen
Adapty August Update: Server notification status, A/B testing CSV, and SDK 1.18-beta
Adapty August Update: Server notification status, A/B testing CSV, and SDK 1.18-beta
September 15, 2022
5 min read
Branch & Adapty webinar: WWDC 2022 recap and in-app purchases updates in 2022
Branch & Adapty webinar: WWDC 2022 recap and in-app purchases updates in 2022
September 1, 2022
47 min listen