Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Это вторая статья из нашего цикла о реализации покупок на Android. В первой статье мы рассказывали о том, как создавать продукты в Google Play Console, сконфигурировать подписки и получить список продуктов в приложении.
В прошлой части мы создали класс-обертку для работы с 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 такого нет. Сообщить о предоставлении доступа к контенту можно двумя способами:
с помощью acknowledgePurchase() со стороны клиента;
либо Purchases.Products.Acknowledge/Purchases.Subscriptions.Acknowledge со стороны бэка.
В случае с 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)
}
}
}
}
Вы могли задаться вопросом, почему кнопки активны для всех продуктов, независимо от того, покупал их пользователь или нет. Или что будет, если купить обе подписки: заменит ли вторая первую или они обе будут сосуществовать. Всё это в следующих статьях :)