Подписки в Android-приложении на Kotlin
Подписочная модель позволяет продавать пользователю регулярный доступ к функциям, контенту или облачным возможностям приложения. В Android для этого используется Google Play Billing Library: она связывает приложение с Google Play, отображает доступные тарифы и обрабатывает покупки через защищённый платёжный интерфейс.
Надёжная реализация подписок требует большего, чем вызов одного метода покупки. Нужно правильно настроить товары в Play Console, получить актуальные предложения, обработать состояния транзакции, подтвердить покупку и синхронизировать права пользователя с сервером. Ниже разобран практический вариант интеграции на Kotlin.
Настройка подписочного товара в Google Play Console
Сначала в Google Play Console создаётся приложение и загружается тестовая сборка с подключённой библиотекой Billing. После этого в разделе монетизации добавляется подписочный товар. Для него задаётся уникальный идентификатор, например premium_monthly, а также базовый план с периодом оплаты — месяц, год или другой доступный интервал.
У одного товара может быть несколько базовых планов и предложений. Например, годовой план может включать пробный период, скидку для новых пользователей или специальную цену на первые несколько месяцев. В приложении нельзя полагаться на заранее зашитую цену: стоимость, валюту и доступные варианты нужно получать из Google Play во время работы.
Для тестирования добавьте лицензионные тестовые аккаунты в Play Console. Сборка должна быть опубликована хотя бы во внутреннем тестовом треке, а тестировщик должен установить приложение из Google Play. Покупки тестовых пользователей не списывают реальные средства, однако проходят основные этапы жизненного цикла подписки.
Подключение Google Play Billing Library
В файл Gradle модуля приложения добавьте зависимость библиотеки:
dependencies {
implementation("com.android.billingclient:billing-ktx:<актуальная-версия>")
}
Версию следует проверять в официальной документации Android, поскольку API и требования Play Store постепенно меняются. Суффикс ktx предоставляет удобные расширения Kotlin, но основная логика работает через BillingClient.
Клиент создаётся с обработчиком покупок и подключается к Google Play:
private val billingClient = BillingClient.newBuilder(context)
.setListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
purchases.orEmpty().forEach(::processPurchase)
}
}
.enablePendingPurchases()
.build()
fun connectToBilling() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
loadSubscriptions()
}
}
override fun onBillingServiceDisconnected() {
// Повторить подключение при следующей операции
}
})
}
Подключение может временно завершиться ошибкой из-за отсутствия сети или недоступности Play Store. Поэтому приложение должно уметь повторять запросы и не считать отсутствие соединения признаком отсутствия подписки.
Получение тарифов и запуск покупки
Для поиска подписок используется QueryProductDetailsParams. В запросе указывается тип SUBS, а также идентификаторы товаров:
private fun loadSubscriptions() {
val product = QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_monthly")
.setProductType(BillingClient.ProductType.SUBS)
.build()
val params = QueryProductDetailsParams.newBuilder()
.setProductList(listOf(product))
.build()
billingClient.queryProductDetailsAsync(params) { result, details ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
val monthly = details.productDetailsList.firstOrNull()
val offer = monthly?.subscriptionOfferDetails?.firstOrNull()
showPrice(monthly, offer)
}
}
}
Для подписок важен offerToken. Он связывает выбранный базовый план или акционное предложение с запуском покупки. Нельзя просто передать идентификатор товара и ожидать, что Google Play самостоятельно выберет нужный тариф.
fun buySubscription(activity: Activity, details: ProductDetails) {
val offer = details.subscriptionOfferDetails?.firstOrNull()
?: return
val productParams = BillingFlowParams.ProductDetailsParams
.newBuilder()
.setProductDetails(details)
.setOfferToken(offer.offerToken)
.build()
val billingParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.build()
billingClient.launchBillingFlow(activity, billingParams)
}
Перед вызовом необходимо проверить, что клиент подключён, товар доступен, а выбранное предложение действительно содержит токен. Ошибки USER_CANCELED, ITEM_UNAVAILABLE и временные сбои следует обрабатывать отдельно, чтобы пользователь получил понятное сообщение.
Состояния покупки и подтверждение транзакции
После оплаты Google Play передаёт объект Purchase. Его состояние может быть PURCHASED или PENDING. Ожидающая транзакция ещё не даёт права на премиум-функции: доступ открывается после перехода покупки в завершённое состояние.
У завершённой покупки проверяется наличие нужного идентификатора товара и активного токена. Затем токен отправляется на сервер для проверки. Подтверждать покупку рекомендуется после успешной серверной валидации:
private fun processPurchase(purchase: Purchase) {
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
return
}
val token = purchase.purchaseToken
sendTokenToServer(token) { verified ->
if (verified && !purchase.isAcknowledged) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(token)
.build()
billingClient.acknowledgePurchase(params) { result ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
// Права уже подтверждены сервером
}
}
}
}
}
Если покупку не подтвердить в установленный Google Play срок, она может быть автоматически отменена и возвращена пользователю. Расходуемые товары используют отдельное подтверждение через consume, но для подписок применяется acknowledgePurchase.
| Задача | Клиент Android | Сервер |
|---|---|---|
| Показать тарифы и цены | Да | Нет |
| Запустить платёжное окно | Да | Нет |
| Проверить токен покупки | Передаёт токен | Да |
| Хранить статус подписки | Локально для кэша | Да, как источник истины |
| Подтвердить покупку | Может вызвать acknowledge | Желательно после проверки |
| Обработать отмену и возврат | Обновляет интерфейс | Получает события Google Play |
Серверная проверка и хранение прав
Клиентское приложение нельзя считать доверенным источником информации о подписке. Пользователь может изменить локальное хранилище, использовать модифицированную сборку или повторно отправить старый токен. Сервер должен проверять покупку через Google Play Developer API и самостоятельно определять, действует ли право доступа.
Обычно приложение отправляет на backend purchaseToken, идентификатор товара и идентификатор пользователя. Сервер проверяет токен, сопоставляет его с аккаунтом, сохраняет дату окончания периода, состояние автопродления и сведения о приостановке или отмене. Один токен нельзя без проверки привязать к нескольким аккаунтам.
Для доступа к Google Play Developer API создаётся сервисный аккаунт, которому выдаются необходимые разрешения в Play Console. Секретные ключи нельзя включать в APK. Ответ API следует обрабатывать с учётом часового пояса, повторных запросов и возможных задержек обновления данных.
Восстановление покупки и синхронизация
При каждом запуске приложения или открытии экрана подписки вызывайте queryPurchasesAsync. Это позволяет найти покупки, совершённые ранее, включая те, которые были оформлены на другом устройстве с тем же аккаунтом Google Play.
fun restoreSubscriptions() {
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(params) { result, purchases ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
purchases.forEach(::processPurchase)
}
}
}
Локальный флаг вроде isPremium = true подходит только для быстрого отображения интерфейса. При восстановлении он должен сверяться с backend. Если устройство долго работало без сети, серверная проверка после подключения обновит права и устранит устаревшее состояние.
Для фоновой синхронизации полезны уведомления Real-time Developer Notifications. Google Play отправляет события о продлении, отмене, возврате средств, приостановке оплаты и завершении периода. Backend принимает уведомление через защищённый endpoint, повторно запрашивает актуальное состояние покупки и обновляет запись пользователя.
Продление, отмена и смена тарифа
Подписка может иметь состояния, которые нельзя свести к простому «активна» или «неактивна». Платёж может быть отклонён, подписка — отменена с сохранением доступа до конца оплаченного периода, а аккаунт — временно приостановлен. Эти варианты должны отображаться в модели данных сервера отдельно.
При отмене автопродления пользователь обычно сохраняет доступ до даты окончания текущего периода. Поэтому приложение не должно немедленно блокировать функции только из-за признака отмены. Доступ прекращается после фактической даты окончания, если подписка не была восстановлена или продлена.
Смена тарифа выполняется через параметры обновления подписки, в которых указывается старая покупка и режим замены. Например, при переходе с месячного плана на годовой Google Play может сделать перерасчёт стоимости. Выбранный режим зависит от бизнес-логики: немедленная замена, перенос остатка или переход после завершения текущего периода.
Архитектура Kotlin-кода и защита от ошибок
Логику BillingClient удобно вынести из Activity в отдельный репозиторий или класс BillingRepository. Состояние экрана можно публиковать через StateFlow: список тарифов, процесс покупки, ошибка подключения и текущий статус доступа. ViewModel будет управлять жизненным циклом данных, а интерфейс останется независимым от деталей Google Play.
Запросы к биллингу нужно выполнять последовательно и учитывать жизненный цикл Activity. Не запускайте оплату после уничтожения экрана, проверяйте состояние подключения и предотвращайте повторный клик по кнопке покупки. Для сетевого обмена с сервером используйте HTTPS, авторизацию пользователя и идемпотентную обработку токенов.
Перед публикацией проверьте сценарии новой покупки, восстановления, отмены, ожидающего платежа, отсутствия сети, повторного запуска приложения и смены устройства. Отдельно протестируйте ситуацию, когда приложение удалено, а подписка продолжает продлеваться: права должны восстанавливаться после повторной установки через синхронизацию с Google Play и сервером.