rzaripov.kz

Подписки в 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 и сервером.