Android In-app Billing: от мобильного приложения до серверной валидации и тестирования

  • Tutorial
image

Всем привет! Недавно передо мной встала задача интегрировать биллинг в наш сервис, и, хотя изначально задача казалась довольно простой, в результате это вылилось в исследование длиной в месяц времени, кучу нервов и открытий. Результатом стало понимание того, что, несмотря на огромное количество документации, не все можно найти простым запросом в Google (а в некоторых местах документация предлагает откровенный бред, о чем я еще расскажу далее).

В результате биллинг от Google Play был успешно интегрирован в наш сервис, валидация покупок и подписок на серверной стороне работает. Кому стало интересно — добро пожаловать под кат: здесь будет полное описание всего, начиная от регистрации покупок в консоли управления Google Play, и заканчивая работой с подписками на своем бекенде.

Для начала коротко о пациенте. Я буду разбирать по кусочкам Google Play In-App Billing V3 а также облачный Android Publisher API, который и поможет нам как с валидацией покупок, так и при работе с подписками. Также не обойдем стороной Консоль управления Google Play — она тоже нам понадобится.

Зачем вообще это нужно?


Если у вас клиент-серверное приложение — то без валидации на сервере вам не обеспечить защиту от пиратства. И хотя можно просто валидировать цифровую подпись покупки на сервере, у запроса на Android Publisher API метода есть некоторые дополнительные возможности. Во-первых, вы можете получить информацию о покупке или подписке в любое время без привязки к устройству пользователя, а, во-вторых, вы можете получить более детальную информацию о подписках и управлять ими (отменять, откладывать и т. п.). К примеру, если вы хотите отобразить дату следующего платежа как в Google Play Music:


То вы можете получить ее только запросом на Android Publisher API.

Полный flow при интеграции биллинга таков:

1. Регистрация приложения в консоли Google Play и создание списка покупок.
2. Интеграция Android in-app billing в мобильном приложении.
3. Валидация покупок и подписок на сервере.

Часть 1: Регистрация приложения в консоли Google Play и создание списка покупок



Зайдите в Консоль управления Google Play (если у вас нет аккаунта — зарегистрируйте его за $25) и создайте ваше первое приложение. Начнем с того момента, когда ваше приложение уже зарегистрировано.

1. Есть ваше приложение не было ранее загружено — подпишите ваше приложение вашим release-сертификатом и загрузите его в закрытое альфа-или бета тестирование.
All Applications / Ваше Приложение / APK / Alpha(Beta) Testing

2. Создайте список тестирования и активируйте его для выбранного вами (Alpha или Beta) типа тестирования.

3. Добавьте в этот список email-ы Google-аккаунтов, которые будет тестировать биллинг. Например, ваш личный email, с помощью которого вы вошли в Google Play на своем устройстве.



Внизу будет ссылка Opt-in URL: по этой ссылке нужно перейти всем пользователям, которые будут тестировать биллинг (и самому тоже), и согласиться на тестирование. Без этого вы не сможете совершать покупки в альфа/бета версии.

4. Перейдите во вкладку Settings / Account Details, найдите раздел LICENSE TESTING и в поле Gmail accounts with testing access добавьте те же email-ы, что и в прошлом шаге. Теперь с этих аккаунтов вы можете тестировать покупки — за них не будет взыматься плата.
Добавить метод оплаты все же придется — сам диалог покупки потребует этого, однако когда вы непострудственно увидите кнопку купить в приложении — будет указано, что это тестовая покупка.

5. Добавьте тестовые покупки в ваше приложение. Для этого пройдите в All Applications / Ваше Приложение / In-app Products и нажмите Add new product. Можете добавить одну покупку (Managed product) и одну подписку (Subscription). В качестве product id можно использовать что-то в стиле com.example.myapp_testing_inapp1 и com.example.myapp_testing_subs1 для покупки и подписки соответственно Нужно как минимум добавить название и описание, установить цену для продукта, выбрать страны, где он доступен (можете выбрать все), для подписки также выбрать период, и активировать продукт. После этого он станет доступен через некоторое время.

ВАЖНО: вы должны опубликовать приложение (как минимум в alpha/beta), иначе покупки работать не будут.

Коротко о типах покупок

1. Managed product (inapp) — одноразовая покупка. После покупки пользователем становится владельцем покупки навсегда, но также такая покупка может быть «использована» (consume) — например, для начисления каких то бонусов. После использования покупка исчезает и ее можно совершить еще раз.

2. Subscription (subs) — подписка. После активации у пользователя снимается определенная сумма раз в определенный период. Пока пользователь платит — подписка активна.


Когда наши покупки будут активированы — мы сможем получить информацию о них непосредственно в мобильном приложении (название, описание, цена в локальной валюте) а также совершить покупку.

Часть 2: Интеграция Android in-app billing в мобильном приложении


Официальная документация

Для начала выполним некоторые манипуляции, чтобы работать с биллинг-сервисом в нашем приложении.

Скопируем файлик IInAppBillingService.aidl в наш проект:

Вольный перевод официальной документации
IInAppBillingService.aidl это файл Android Interface Definition Language (AIDL), который определяет интерфейс взаимодействия с сервисом In-app Billing Version 3. Вы будете использовать этот интерфейс для выполнения биллинг-запросов с помощью IPC-вызовов.

Чтобы получить файл AIDL:
Откройте Android SDK Manager.
В SDK Manager найдите и раскройте секцию Extras.
Выберите Google Play Billing Library.
Нажмите Install packages чтобы выполнить установку.
Перейдите в папку src/main вашего проекта и создайте папку с именем aidl.
Внутри этот папки создайте пакет com.android.vending.billing.
Скопируйте файл IInAppBillingService.aidl из папки %anroid-sdk%/extras/google/play_billing/ в только что созданный пакет src/main/aidl/com.android.vending.billing

Добавим разрешение в манифест:

<uses-permission android:name="com.android.vending.BILLING" />

И в месте, где мы собираемся совершать покупки, подключимся к сервису:

    IInAppBillingService inAppBillingService;

    ServiceConnection serviceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            inAppBillingService = IInAppBillingService.Stub.asInterface(service);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            inAppBillingService = null;
        }
    };

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent serviceIntent = 
                new Intent("com.android.vending.billing.InAppBillingService.BIND");
        serviceIntent.setPackage("com.android.vending");
        bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE);
        ...
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (serviceConnection != null) {
            unbindService(serviceConnection);
        }
    }

Теперь можно приступать к работе с покупками. Получим список наших покупок из сервиса с описанием и ценами:

    class InAppProduct {

        public String productId;
        public String storeName;
        public String storeDescription;
        public String price;
        public boolean isSubscription;
        public int priceAmountMicros;
        public String currencyIsoCode;

        public String getSku() {
            return productId;
        }
        
        String getType() {
            return isSubscription ? "subs" : "inapp";
        }
        
    }

    List<InAppProduct> getInAppPurchases(String type, String... productIds) throws Exception {
        ArrayList<String> skuList = new ArrayList<>(Arrays.asList(productIds));
        Bundle query = new Bundle();
        query.putStringArrayList("ITEM_ID_LIST", skuList);
        Bundle skuDetails = inAppBillingService.getSkuDetails(
                3, context.getPackageName(), type, query);
        ArrayList<String> responseList = skuDetails.getStringArrayList("DETAILS_LIST");
        List<InAppProduct> result = new ArrayList<>();
        for (String responseItem : responseList) {
           JSONObject jsonObject = new JSONObject(responseItem);
            InAppProduct product = new InAppProduct();
            // "com.example.myapp_testing_inapp1"
            product.productId = jsonObject.getString("productId");
            // Покупка
            product.storeName = jsonObject.getString("title");
            // Детали покупки
            product.storeDescription = jsonObject.getString("description");
            // "0.99USD"
            product.price = jsonObject.getString("price");
            // "true/false"
            product.isSubscription = jsonObject.getString("type").equals("subs");
            // "990000" = цена x 1000000
            product.priceAmountMicros = 
                    Integer.parseInt(jsonObject.getString("price_amount_micros"));
            // USD
            product.currencyIsoCode = jsonObject.getString("price_currency_code");
            result.add(product);
        }
        return result;
    }

С помощью этого метода мы можем загрузить данные о доступных покупках.

        // для покупок
        List<InAppProduct> purchases = 
                getInAppPurchases("inapp", "com.example.myapp_testing_inapp1");
        // для продписок
        List<InAppProduct> subscriptions =
                getInAppPurchases("subs", "com.example.myapp_testing_subs1");

Теперь мы можем прямо из приложения получить список покупок и информацию про них. Цена будет указана в той валюте, в которой пользователь будет платить. Эти методы надо вызывать в фоновом потоке, так как сервис в процессе может загружать данные с серверов Google. Как использовать эти данные — на ваше усмотрение. Вы можете отобразить цены и названия продуктов из полученного списка, а можете названия и цены указать в ресурсах приложения.

Самое время теперь что-то купить!

    private static final int REQUEST_CODE_BUY = 1234;

    public static final int BILLING_RESPONSE_RESULT_OK = 0;
    public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1;
    public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2;
    public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3;
    public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4;
    public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5;
    public static final int BILLING_RESPONSE_RESULT_ERROR = 6;
    public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7;
    public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8;

    public static final int PURCHASE_STATUS_PURCHASED = 0;
    public static final int PURCHASE_STATUS_CANCELLED = 1;
    public static final int PURCHASE_STATUS_REFUNDED = 2;

    public void purchaseProduct(InAppProduct product) throws Exception {
        String sku = product.getSku();
        String type = product.getType();
        // сюда вы можете добавить произвольные данные
        // потом вы сможете получить их вместе с покупкой
        String developerPayload = "12345";
        Bundle buyIntentBundle = inAppBillingService.getBuyIntent(
                3, context.getPackageName(),
                sku, type, developerPayload);
        PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT");
        startIntentSenderForResult(pendingIntent.getIntentSender(),
                REQUEST_CODE_BUY, new Intent(), Integer.valueOf(0), Integer.valueOf(0),
                Integer.valueOf(0), null);
    }

    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_CODE_BUY) {
            int responseCode = data.getIntExtra("RESPONSE_CODE", -1);
            if (responseCode == BILLING_RESPONSE_RESULT_OK) {
                String purchaseData = data.getStringExtra("INAPP_PURCHASE_DATA");
                String dataSignature = data.getStringExtra("INAPP_DATA_SIGNATURE");
                // можете проверить цифровую подпись 
                readPurchase(purchaseData);
            } else {
                // обрабатываем ответ
            }
        }
    }
    
    private void readPurchase(String purchaseData) {
        try {
            JSONObject jsonObject = new JSONObject(purchaseData);
            // ид покупки, для тестовой покупки будет null
            String orderId = jsonObject.optString("orderId");
            // "com.example.myapp"
            String packageName = jsonObject.getString("packageName");
            // "com.example.myapp_testing_inapp1"
            String productId = jsonObject.getString("productId");
            // unix-timestamp времени покупки
            long purchaseTime = jsonObject.getLong("purchaseTime");
            // PURCHASE_STATUS_PURCHASED
            // PURCHASE_STATUS_CANCELLED
            // PURCHASE_STATUS_REFUNDED
            int purchaseState = jsonObject.getInt("purchaseState");
            // "12345"
            String developerPayload = jsonObject.optString("developerPayload");
            // токен покупки, с его помощью можно получить
            // данные о покупке на сервере
            String purchaseToken = jsonObject.getString("purchaseToken");
            // далее вы обрабатываете покупку
            ...
        } catch (Exception e) {
            ...
        }
    }

Отдельно хочется сказать про dataSignature. Пример ее проверки есть тут, но если ваша покупка валидируется на сервере — то это лишний шаг.

Также может быть полезной возможность получить информацию о уже совершенных покупках:

    private void readMyPurchases() throws Exception {
        readMyPurchases("inapp"); // для покупок
        readMyPurchases("subs"); // для подписок
    }

    private void readMyPurchases(String type) throws Exception {
        String continuationToken = null;
        do {
            Bundle result = inAppBillingService.getPurchases(
                    3, context.getPackageName(), type, continuationToken);
            if (result.getInt("RESPONSE_CODE", -1) != 0) {
                throw new Exception("Invalid response code");
            }
            List<String> responseList = result.getStringArrayList("INAPP_PURCHASE_DATA_LIST");
            for (String purchaseData : responseList) {
                readPurchase(purchaseData);
            }
            continuationToken = result.getString("INAPP_CONTINUATION_TOKEN");
        } while (continuationToken != null);
    }

Это тоже нужно выполнять из фонового потока. Здесь вернется список покупок, которые мы совершили ранее. Также можно получить и список активных подписок.

Следующий шаг — использование покупки. Имеется в виду, что вы начисляете пользователю что-то за покупку, а сама покупка пропадает, давая таким возможность совершить покупку еще раз.

    private void consumePurchase(String purchaseToken) throws Exception {
        int result = inAppBillingService.consumePurchase(GooglePlayBillingConstants.API_VERSION,
                context.getPackageName(), purchaseToken);
        if (result == GooglePlayBillingConstants.BILLING_RESPONSE_RESULT_OK) {
            // начисляем бонусы
            ...
        } else {
            // обработка ошибки
            ...
        }

    }


После этого вы уже не сможете прочитать данные о покупке — она будет недоступна через getPurchases().

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

Часть 3: Валидация покупок и подписок на сервере



Это самая интересная часть, над которой я бился дольше всего. Все примеры будут на java, для которой Google предоставляет готовую библиотеку для работы со своими сервисами.

Библиотеки и для других языков можно поискать здесь. Документация по Google Publisher API находится тут, в контексте текущей задачи нас интересуют Purchases.products и Purchases.subscriptions.

По сути, главная проблема, с которой я столкнулся, это описание способа авторизации. Даже по самому описанию он выглядит как пятая нога у коня, но проблема не в том, что он не работает, а в том, что он в корне не верный для нашей задачи. Просьба к знатокам не кидаться в меня камнями: OAuth предназначен для работы с ресурсами клиента, в нашем же случае backend-сервис обращается за данными биллинга нашего собственного приложения.

И вот тут нам на помощь приходит IAM (Identy Access Management). Нам нужно создать проект в Google Cloud Console и зайти во вкладку Credentials, выбрать Create credentials → Service account key.

image

Заполните данные так, как показано на картинке:
image
Service account: New service account
Service account name: имя на выбор
Role: не выбирайте, она сейчас не нужна
Key type: JSON

Нажимаете Create. Вылезет окошко с предупреждением Service account has no role. Соглашается, выбираем CREATE WITHOUT ROLE. Вам автоматически загрузится JSON-файл с данными для авторизации аккаунта. Сохраните этот файл — в будущем он понадобится для того, чтобы авторизоваться на Google-сервисах.

Пример файла
{
  "type": "service_account",
  "project_id": "project-name",
  "private_key_id": "1234567890abcdef1234567890abcdef",
  "private_key": "-----BEGIN PRIVATE KEY-----\XXXXX.....XXXXX\n-----END PRIVATE KEY-----\n",
  "client_email": "myaccount@project-name.iam.gserviceaccount.com",
  "client_id": "12345678901234567890",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://accounts.google.com/o/oauth2/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/myaccount%40project-name.iam.gserviceaccount.com"
}


Теперь возвращаемся на вкладку Credentials нашего проекта и видим внизу список Service account keys. Справа кнопка Manage service accounts — нажимаем на нее и видим:

image

myaccount@project-name.iam.gserviceaccount.com — это и есть id нашего аккаунта. Копируем его и идем в Google Play Developer Console → Settings → User Accounts & Rights и выбираем Invite new user.

Заполняем данные.

image

Вставляем id аккаунта в поле Email, добавляем наше прилождение и ставим галочку напротив View financial reports.

Нажимаем Send Invitation. Теперь мы можем использовать наш JSON-файл для авторизации и Google API и доступа к данным покупок и подписок нашего приложения.

Следующий шаг — нужно активировать Google Play Developer API для нашего проекта. Идем в Google Developer Console → Library и ищем Google Play Developer API. Открываем его и нажимаем Enable.



Последний шаг настройки — идем в Google Play Developer Console → Settings → API Access.



В списке находим наш проект (на картинке выше это Google Play Android Developer, но там должно быть имя вашего проекта) и нажимаем Link.



Теперь перейдем к разработке серверной части

Как вы будете хранить JSON-файл с приватными данными IAM-аккаунта оставим на ваше усмотрение. Импортируйте Google Play Developer API в ваш проект (mavencentral) и реализуем проверку.

Данные о покупке нужно отправить с нашего приложения на сервер. Сама реализация проверки на сервере выглядит вот так:

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.androidpublisher.AndroidPublisher;
import com.google.api.services.androidpublisher.AndroidPublisherScopes;
import com.google.api.services.androidpublisher.model.ProductPurchase;
import com.google.api.services.androidpublisher.model.SubscriptionPurchase;

import java...

public class GooglePlayService {

    private final Map<String, AndroidPublisher> androidPublishers = new HashMap<>();
    
    private String readCredentialsJson(String packageName) {
        // здесь надо прочитать данные из JSON-файла и вернуть их
        ...
    }

    private AndroidPublisher getPublisher(String packageName) throws Exception {
        if (!androidPublishers.containsKey(packageName)) {
            String credentialsJson = readCredentialsJson(packageName);
            InputStream inputStream = new ByteArrayInputStream(
                    credentialsJson.getBytes(StandardCharsets.UTF_8));
            HttpTransport transport = GoogleNetHttpTransport.newTrustedTransport();
            GoogleCredential credential = GoogleCredential.fromStream(inputStream)
                    .createScoped(Collections.singleton(
                            AndroidPublisherScopes.ANDROIDPUBLISHER));
            AndroidPublisher.Builder builder = new AndroidPublisher.Builder(
                    transport, JacksonFactory.getDefaultInstance(), credential);
            AndroidPublisher androidPublisher = builder.build();
            androidPublishers.put(packageName, androidPublisher);
        }
        return androidPublishers.get(packageName);
    }

    public ProductPurchase getPurchase(String packageName,
                                       String productId,
                                       String token) 
            throws Exception {
        AndroidPublisher publisher = getPublisher(packageName);
        AndroidPublisher.Purchases.Products.Get get = publisher
                .purchases().products().get(packageName, productId, token);
        return get.execute();
    }

    public SubscriptionPurchase getSubscription(String packageName, 
                                                String productId, 
                                                String token) 
            throws Exception {
        AndroidPublisher publisher = getPublisher(packageName);
        AndroidPublisher.Purchases.Subscriptions.Get get = publisher
                .purchases().subscriptions().get(packageName, productId, token);
        return get.execute();
    }

}

Таким образом мы получаем возможность получить данные о нашей покупке непосредственно от Google, потому пропадает необходимость в проверке подписи. Более того, для подписок вы можете получить намного больше информации, чем непосредственно через IInAppBilligService в мобильном приложении.

В качестве параметров запроса нам нужны:

  • packageName — имя пакета приложения (com.example.myapp)
  • productId — идентификатор продукта (com.example.myapp_testing_inapp1)
  • token — уникальный токен покупки, который вы получили в мобльном приложении:

    String purchaseToken = jsonObject.getString("purchaseToken");

Детали по ProductPurchase и SubscriptionPurchase описаны в документации, не будем на них останавливаться.

Вместо заключения


Сначала казавшаяся простой задача по интеграции биллинга в наш сервис превратилась в путешествие через документацию, гуглинг и бессилие (OAuth, ты прекрасен), так как про использование IAM для целей доступа в документации ни слова. Серьезно, они предлагают вбить руками какой-то руками состряпанный URL в вашем браузере, добавить origin для редиректа в консоли управления проектом, и все это для того, чтобы получить одноразовый токен, который надо руками передать на сервер, после чего использовать весь флоу OAuth для получения доступа к данным биллинга. Это не говоря о том, что если вы не успеете использовать refresh-token, то вам придется получать новый токен — руками. Согласитесь — это звучит как полный бред для backend-сервиса, который должен работать без вмешательства человека.

Я надеюсь, что этой статьей помогу кому-то сэкономить немного времени и нервов.
  • +8
  • 28,2k
  • 3
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 3
  • +1
    что интересно — знатоки не только не бросались какашками, но даже ни как не прокомментировали пост, а их комменты были бы весьма интересны
    • 0
      Не могу ничего сказать про серверную часть, но на клиенте можно было бы использовать какое-нибудь готовое решение, например, библиотеку Checkout.
    • 0
      Вот что Apple, что Google не могут без приколов сделать покупки в приложениях. У Apple невозможно засунуть в покупку developer payload.
      А у Google никак нельзя использовать orderId в качестве идентификатора транзакции т.к. нет способа (или очень глубоко запрятан) его сверить на сервере. В AndroidPublisher API /products/get он не возвращается.
      Хотя Google пишет: In your app, you can use the order number as a general-purpose identifier for the in-app purchase transaction. After the purchase, you can use the order number as a means of tracking the transaction in reconciliation reports and for customer support.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое