Привет всем, я Влад, core разработчик Android SDK для обработки платежей в мобильных приложениях в Adapty

Это вторая статья из нашего цикла о реализации покупок на Android. В первой статье мы рассказывали о том, как создавать продукты в Google Play Console, сконфигурировать подписки и получить список продуктов в приложении. Cоветую познакомиться и с остальными:

  1. Android in-app purchases, часть 1: конфигурация и добавление в проект.

  2. Android in-app purchases, часть 2: инициализация и обработка покупок. — Вы тут

  3. Android in-app purchases, часть 3: получение активных покупок и смена подписки.

  4. Android in-app purchases, часть 4: коды ошибок от Billing Library и как не облажаться с тестированием.

  5. Android in-app purchases, часть 5: серверная валидация покупок.

В прошлой части мы создали класс-обертку для работы с Billing Library:

Класс-обертка для работы с 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
   }
}

Перейдем дальше к реализации покупки и дополним наш класс.

Создание экрана с подписками

В любом приложении, которое использует встроенные покупки, присутствует пейволл. Есть требования от Google, которые определяют минимальный набор необходимых элементов и поясняющих текстов для подобных экранов. Если коротко, на пейволле вы должны прозрачно показать пользователю условия, цену и длительность подписки и обязательна ли подписка для использования приложения. Нельзя принуждать пользователя к дополнительным действиям ради получения информации об условиях подписки.

На данном этапе для примера мы сделали упрощённый вариант пейволла:

Итак, на нашем пейволле располагаются следующие элементы:

  • Заголовок.

  • Набор кнопок для инициализации процесса покупки. На них указаны основные свойства продуктов: название и цена в местной валюте.

  • Поясняющий текст.

  • Кнопка восстановления прошлых покупок. Этот элемент необходим для всех приложений, в которых используются подписки либо non-consumable покупки.

Доработка кода для отображения информации о продуктах

В нашем примере четыре продукта:

  • две автовозобновляемые подписки ("premium_sub_month" и "premium_sub_year");

  • продукт, который нельзя купить повторно, — non-consumable (“unlock_feature”);

  • продукт, который можно покупать много раз, — consumable (“coin_pack_large”).

Для упрощения примера будем использовать Activity, в которую заинжектим BillingClientWrapper из предыдущей статьи, и layout с жестко заданным количеством кнопок для покупки.

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

Для удобства добавим словарь, где ключом является sku продукта, а значением - соответствующая кнопка на экране.

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

Объявим метод для отображения продуктов в UI, опираясь на логику из предыдущей статьи:

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 — это уже отформатированная строка с указанием местной валюты для данного аккаунта, здесь никакое дополнительное форматирование не нужно; остальные поля в объекте класса SkuDetails также приходят уже с учетом локализации.

Запуск процесса покупки

Для проведения покупки необходимо вызвать метод launchBillingFlow() из главного потока приложения.

Добавим для этого метод purchase() в BillingClientWrapper:

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

У метода launchBillingFlow() нет колбэка, ответ вернется в метод onPurchasesUpdated(). Помните, мы в прошлой статье его объявили, но оставили на потом? Вот сейчас он нам понадобится.

Метод onPurchasesUpdated() вызывается при каком-либо результате после взаимодействия пользователя с диалогом покупки. Это может быть успешная покупка, отмена покупки (пользователь закрыл диалог, в этом случае приходит код BillingResponseCode.USER_CANCELED) или же какая-то другая ошибка.

По аналогии с интерфейсом OnQueryProductsListener из предыдущей статьи, объявим в классе BillingClientWrapper интерфейс OnPurchaseListener, с помощью которого будем получать либо покупку (объект класса Purchase — о кейсе, когда он может быть null даже в случае успеха, расскажем в следующей статье), либо ошибку, которую мы также объявляли в предыдущей статье:

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

var onPurchaseListener: OnPurchaseListener? = null

И реализуем его в 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
           }
       })
   }
}

Добавим логику в 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
               )
           )
       }
   }
}

Если purchaseList не пустой, для начала для каждой покупки делаем проверку, что ее purchasedState равен PurchaseState.PURCHASED, потому что покупки также могут быть отложенными, и в этом случае флоу на данном этапе прекращается. Далее, согласно документации, нужно сделать серверную верификацию покупки, о ней мы расскажем в следующих статьях. После этого надо предоставить пользователю доступ к контенту и сообщить об этом в Google. Если не сообщить, то через три дня покупка автоматически отменится. Интересно, что это характерно только для Google Play, в то время как на iOS такого нет. Сообщить о предоставлении доступа к контенту можно двумя способами:

В случае с consumable-продуктом вместо него нужно вызвать метод consumeAsync(), который под капотом делает acknowledge, а заодно дает возможность покупать этот продукт повторно. Это можно сделать только с помощью Billing Library: Google Play Developer API почему-то не предоставляет возможность делать это на бэкенде. Любопытно, что, в отличие от Google Play, в App Store и в AppGallery свойство consumable за продуктом жестко определено на уровне App Store Connect и AppGallery Connect соответственно. Справедливости ради, замечу, что консьюмить такие продукты из AppGallery нужно всё-таки явно.

Напишем методы для acknowledge и consume, а также две версии метода processPurchase() — в случае, когда у нас есть свой бэкенд и когда его нет.

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

Без серверной верификации:

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

С серверной верификацией:

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

Подробнее о серверной верификации покупок мы расскажем в одной из следующих статей.

Во втором примере acknowledge, конечно, тоже можно было сделать на клиенте, но так как здесь у нас есть бэкенд, всё, что можно сделать на бэке, лучше отдать бэку. Что касается ошибок на acknowledge и consume, их нельзя игнорировать, потому что если ни одно из этих действий не произойдет в течение трёх дней после того, как покупка получила статус PurchaseState.PURCHASED, она отменится, а пользователю вернут средства. Поэтому, если мы не можем это сделать на бэкенде, и даже после нескольких повторных попыток всё еще получаем ошибку, самый надежный способ — получать текущие покупки пользователя в каком-нибудь методе жизненного цикла, например в onStart() или onResume(), и пытаться повторить в надежде, что пользователь в течение трёх дней зайдет в наше приложение при работающем интернете :).

Таким образом, текущая версия класса BillingClientWrapper будет выглядеть так:

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

Вы могли задаться вопросом, почему кнопки активны для всех продуктов, независимо от того, покупал их пользователь или нет. Или что будет, если купить обе подписки: заменит ли вторая первую или они обе будут сосуществовать. Всё это в следующих статьях :)

Про Adapty

Cоветую познакомиться с Adapty — SDK для встроенных покупок.

Он не только упрощает работу по добавлению покупок:

  • Встроенная аналитика позволяет быстро понять основные метрики приложения.

  • Когортный анализ отвечает на вопрос, как быстро сходится экономика.

  • А/Б тесты увеличивают выручку приложения.

  • Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.

  • Промо-кампании уменьшают отток аудитории.

  • Open source SDK позволяет интегрировать подписки в приложение за несколько часов.

  • Серверная валидация и API для работы с другими платформами.

Познакомьтесь подробнее с этими возможностями, чтобы быстрее внедрить подписки в своё приложение и улучшить конверсии.

Комментарии (9)


  1. web_alex
    06.08.2021 12:06

    Благодарю Вас за интересный и полезный цикл статей, vladg7!

    Хочу уточнить, будет ли в Вашем цикле статей информация о проверки подлинности покупок? К примеру, с использованием Firebase Cloud Functions.

    К сожалению, есть пользователи, которые подменяют оригинальный Google Billing сервер своим. Это приводит к недополучения вознаграждения с продаж материалов внутри приложения, и разбалансированности игрового процесса (вспоминаем ArtMoney под windows для NFS, WarCraft, etc.)


    1. vladg7 Автор
      06.08.2021 12:47
      +1

      Спасибо! Да, одна из следующих статей будет про серверную валидацию


      1. web_alex
        06.08.2021 17:59

        Супер! Мне будет очень интересно почитать про Ваш подход. Жду с нетерпением!


  1. Firsto
    07.08.2021 02:03
    +2

    Не то чтобы что-то новое узнал, но написано очень хорошо! ​(ᵔᴥᵔ)


    1. web_alex
      07.08.2021 12:44
      +1

      да, тема автором статьи изложена грамотно, что будет очень полезно для тех разработчиков, которые с английским еще пытаются подружиться.


    1. vladg7 Автор
      10.08.2021 11:31
      +1

      Спасибо!


  1. petrovichtim
    09.08.2021 05:02

    Спасибо за статью, non-consumable и consumable покупки где-то отдельно настраиваются в Google Play ?


    1. web_alex
      09.08.2021 10:36

      нет. В Google Play Console есть подписка и товар\продукт.

      Второй можно из приложения пометить потребленным, вызвав соответствующийметод Google Billing API.


    1. vladg7 Автор
      10.08.2021 11:31

      Нет, всё, как написал @web_alex, - подписки создаются в одном разделе, обычные продукты в другом, и в Play Console для consumable/non-consumable даже нет соответствующего флажка (то есть, технически можно консьюмить любую неподписку).

      А вот, например, в AppGallery есть строгое разделение consumable и non-consumable, и non-consumable продукты технически консьюмить нельзя (можно только при тестировании).