---
title: "Zakupy w aplikacji na Androida, część 2: przetwarzanie zakupów za pomocą Google Play Billing Library"
description: "Drugi samouczek z naszej serii na temat wdrażania zakupów w aplikacji na Androida. Jak przetwarzać zakupy i obsługiwać zmiany subskrypcji"
url: "https://adapty.io/pl/blog/android-in-app-purchases-google-play-billing-library-part-2/"
language: "pl"
slug: "android-in-app-purchases-google-play-billing-library-part-2"
category: "adapty-updates"
date: "2021-03-24T00:00:00.000Z"
date_modified: "2021-03-24T00:00:00.000Z"
author: "Vlad Guriev"
---

# 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:

```kotlin
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ę.

## Projektowanie ekranu subskrypcji

Każda aplikacja, która oferuje zakupy w aplikacji, posiada ekran paywall. Istnieją pewne [Polityki Google](https://support.google.com/googleplay/android-developer/answer/9900533), 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:

![631abc67c79cf6614ff8de49 3321](https://adapty.io/wp-content/uploads/2023/01/631abc67c79cf6614ff8de49_3321.png)
*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.

```kotlin
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](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails#getSku%28)) produktu, a wartością jest odpowiedni przycisk na ekranie.

```kotlin
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](https://adapty.io/blog/android-in-app-subscriptions-part-1/) tutorial.

```kotlin
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](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails) są również w pełni zlokalizowane.

## Rozpoczęcie procesu zakupu

Aby przetworzyć zakup, musimy powołać się na metodę [launchBillingFlow()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchBillingFlow%28android.app.Activity,%20com.android.billingclient.api.BillingFlowParams)) z głównego wątku aplikacji.

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

```kotlin
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](https://adapty.io/blog/android-in-app-subscriptions-part-1/), 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*](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.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](https://developer.android.com/reference/com/android/billingclient/api/Purchase)) lub komunikat o błędzie zadeklarowany przez nas w [poprzednim poradniku](https://adapty.io/blog/android-in-app-subscriptions/). W następnym omówimy przypadek, w którym obiekt klasy Purchase może mieć wartość null, nawet jeśli zakup się powiódł.

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

var onPurchaseListener: OnPurchaseListener? = null
```

Następnie zaimplementujemy go w PaywallActivity:

```kotlin
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():

```kotlin
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](https://developer.android.com/google/play/billing/integrate#pending), ponieważ istnieją także zakupy [oczekujące](https://developer.android.com/google/play/billing/integrate#pending). W takim przypadku przepływ użytkownika kończy się w tym miejscu. Według [docs](https://developer.android.com/google/play/billing/integrate#process), 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:

- Przez [acknowledgePurchase()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#acknowledgepurchase) na stronie klienta;
- Przez [Product.Purchases.Acknowledge](https://developers.google.com/android-publisher/api-ref/purchases/products/acknowledge)/[Purchases.Subscriptions.Acknowledge](https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/acknowledge) w backendzie.

Jeśli mamy do czynienia z produktem konsumpcyjnym, musimy powołać się zamiast tego na metodę [consumeAsync()](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#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.

Napiszmy metody dla potwierdzenia i konsumpcji, a także dwie wersje metody processPurchase(), aby wyjaśnić, czy uruchamiamy nasz własny backend.

```kotlin
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:

```kotlin
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:

```kotlin
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*](https://developer.android.com/reference/com/android/billingclient/api/Purchase.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()](https://developer.android.com/reference/androidx/lifecycle/Lifecycle.Event) 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:

```kotlin
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 :)
