image

И как это до сих пор на Хабре нет статьи об этом? Не дело, надо исправлять.

Есть 2 способа добавить In-App покупки в Android-приложение — старый и новый. До 2017 года все пользовались библиотекой от anjlab, но с июня 2017 года ситуация изменилась, Google выпустила собственную библиотеку для внутренних покупок и подписок — Play Billing Library. Сейчас последний считается стандартом.

Play Billing Library это очень просто.

Подключите зависимость.

implementation 'com.android.billingclient:billing:1.2'

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

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

Создайте инстанс BillingClient и начните соединение.


private BillingClient mBillingClient;
...
mBillingClient = BillingClient.newBuilder(this).setListener(new PurchasesUpdatedListener() {
    @Override
    public void onPurchasesUpdated(int responseCode, @Nullable List<Purchase> purchases) {
        if (responseCode == BillingClient.BillingResponse.OK && purchases != null) {
            //сюда мы попадем когда будет осуществлена покупка

        }
    }
}).build();
mBillingClient.startConnection(new BillingClientStateListener() {
    @Override
    public void onBillingSetupFinished(@BillingClient.BillingResponse int billingResponseCode) {
        if (billingResponseCode == BillingClient.BillingResponse.OK) {
            //здесь мы можем запросить информацию о товарах и покупках

        }
    }

    @Override
    public void onBillingServiceDisconnected() {
        //сюда мы попадем если что-то пойдет не так
    }
});

В метод onPurchasesUpdated() мы попадаем когда покупка осуществлена, в методе onBillingSetupFinished() можно запросить информацию о товарах и покупках.

Запросить информацию о товарах. Поместите querySkuDetails() в onBillingSetupFinished().


private Map<String, SkuDetails> mSkuDetailsMap = new HashMap<>();
private String mSkuId = "sku_id_1";
...
@Override
public void onBillingSetupFinished(@BillingClient.BillingResponse int billingResponseCode) {
    if (billingResponseCode == BillingClient.BillingResponse.OK) {
        //здесь мы можем запросить информацию о товарах и покупках
        querySkuDetails(); //запрос о товарах

    }
}
...
private void querySkuDetails() {
    SkuDetailsParams.Builder skuDetailsParamsBuilder = SkuDetailsParams.newBuilder();
    List<String> skuList = new ArrayList<>();
    skuList.add(mSkuId);
    skuDetailsParamsBuilder.setSkusList(skuList).setType(BillingClient.SkuType.INAPP);
    mBillingClient.querySkuDetailsAsync(skuDetailsParamsBuilder.build(), new SkuDetailsResponseListener() {
        @Override
        public void onSkuDetailsResponse(int responseCode, List<SkuDetails> skuDetailsList) {
            if (responseCode == 0) {
                for (SkuDetails skuDetails : skuDetailsList) {
                    mSkuDetailsMap.put(skuDetails.getSku(), skuDetails);
                }
            }
        }
    });
}

В коде вы могли заметить понятие SKU, что это? SKU — от английского Stock Keeping Unit (идентификатор товарной позиции).

Теперь в mSkuDetailsMap у нас лежит вся информация о товарах (имя, описание, цена), зарегистрированных в Play Console данного приложения (об этом позже). Обратите внимание на эту строку skuList.add(mSkuId);, здесь мы добавили id товара из Play Console, перечислите здесь все товары, с которыми вы хотите взаимодействовать. У нас товар один —sku_id_1.

Все готово к тому, чтобы выполнить запрос на покупку. Передаем id товара. Запустите этот метод, например, по клику на кнопку.

public void launchBilling(String skuId) {
    BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
            .setSkuDetails(mSkuDetailsMap.get(skuId))
            .build();
    mBillingClient.launchBillingFlow(this, billingFlowParams);
}

Теперь, запустив этот метод, вы увидите вот такое диалоговое окно (прим. картинки из Интернета).

image

Теперь если пользователь купит товар — его ему надо предоставить. Добавьте метод payComplete() и осуществите в нем действия, предоставляющие доступ к купленному товару. Например, если пользователь покупал отключение рекламы, сделайте в этом методе так, чтобы реклама больше не показывалась.

...
@Override
public void onPurchasesUpdated(int responseCode, @Nullable List<Purchase> purchases) {
    if (responseCode == BillingClient.BillingResponse.OK && purchases != null) {
        //сюда мы попадем когда будет осуществлена покупка
        payComplete();
    }
}
...

Все хорошо, но если пользователь перезапустит приложение, наша программа ничего не знает о покупках. Надо запросить информацию о них. Сделайте это в onBillingSetupFinished().


@Override
public void onBillingSetupFinished(@BillingClient.BillingResponse int billingResponseCode) {
    if (billingResponseCode == BillingClient.BillingResponse.OK) {
        //здесь мы можем запросить информацию о товарах и покупках
        querySkuDetails(); //запрос о товарах
        List<Purchase> purchasesList = queryPurchases(); //запрос о покупках

        //если товар уже куплен, предоставить его пользователю
        for (int i = 0; i < purchasesList.size(); i++) {
            String purchaseId = purchasesList.get(i).getSku();
            if(TextUtils.equals(mSkuId, purchaseId)) {
                payComplete();
            }
        }
    }
}
...
private List<Purchase> queryPurchases() {
    Purchase.PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP);
    return purchasesResult.getPurchasesList();
}

В purchasesList попадает список всех покупок, сделанных пользователем.

Делаем проверку: если товар куплен — выполнить payComplete().

Готово. Осталось это приложение опубликовать в Play Console и добавить товары. Как добавить товар: Описание страницы приложения > Контент для продажи > Создать ограниченный контент.

Примечание 1: Вы не сможете добавить товар пока не загрузите билд приложения в Play Console.

Примечание 2: Чтобы увидеть диалоговое окно о покупке, вам надо загрузить билд в Play Console, добавить товар и подождать какое-то время (~30 минут — 1 час — 3 часа), пока товар обновится, только после этого появится диалоговое окно и можно будет осуществить покупку.

Примечание 3: Ошибка Please fix the input params. SKU can't be null — товар в Play Console еще не успел обновиться, подождите.

Примечание 4: Вы можете столкнуться с ошибкой Error «Your transaction cannot be completed», в логах как response code 6 пока будете тестировать. По каким причинам это происходит мне точно неизвестно, но по моим наблюдениям это происходит после частых манипуляций с покупкой и возвратом товара. Чтобы это починить перейдите в меню банковских карт и передобавьте вашу карту. Как этого избежать? Добавьте ваш аккаунт в Play Console в качестве тестировщика и покупайте только с тестовой карточки.

Демо на GitHub

Купите мне кофе

(Кстати, на Хабре работает система донейтов по кнопке под статьёй — прим. модератора).

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


  1. web_alex
    17.03.2019 21:50
    +1

    Да, всегда юзал anjlab. Никак не мог понять сэмплы Google Billing'a на GitHub.
    А либа в этой статье и Ваши примеры хороши! Еще не внедрял нигде, но на заметку взял.
    Благодарю за статью!
    +1 в пост и карму!
    Было познавательно! ;-)


  1. denonlink
    18.03.2019 01:07

    До 2017 года все пользовались библиотекой от anjlab

    Не все. Я ей не пользовался, а вместо этого просто расковырял документацию, разобрался и сделал все так, как надо.


    1. GavriKos
      18.03.2019 10:44

      Притом что ничего сложного в нативной платежке нет. Зато позволяет понять получше весь биллинг и лучше понимать ошибки, которые могут возникать в процессе.
      Единственная сложность, какая была — получение цены в валюте пользователя и приведение ее к нужному формату.


      1. kmansoft
        18.03.2019 11:04
        +1

        Вот-вот. Просто изначальная «библиотека» (программа-пример в официальной документации) была полной лажей, нестабильной и усложнённой. Ну это если кто помнит, я про 2011 год или около того…

        > Единственная сложность, какая была — получение цены в валюте пользователя и приведение ее к нужному формату.

        Я последний раз трогал InApp год назад, уже тоже обошёлся без библиотек.

        Цена была сразу в валюте пользователя, с единицей измерения в 1/1000 настоящей валютной единицы кажется (то есть скажем 1/10 копейки).


        1. GavriKos
          18.03.2019 11:12

          Ну не знаю что там «нестабильного» — все работало. Я тоже о 2011-2013 — щас на библиотеках, правда на других (unity IAP).

          Вот да, вот эта 1/1000 и подкашивала. Вроде были еще какие то трабблы чтобы получить локализированное название валюты, причем в каких то случаях оно до числа пишется, в каких то после. Но — это все очень глубокое копание, которое я и в этой статье не увидел.


          1. kmansoft
            18.03.2019 11:26

            Ну не знаю что там «нестабильного» — все работало. Я тоже о 2011-2013 — щас на библиотеках, правда на других (unity IAP).

            Я про Sample которая шла в качестве библиотеки / примера когда InApp вообще появился.


            Там были гонки потоков, утечка Service-а (или вылет при попытке его освободить)… Также серверная сторона иногда глючила. В общем хватало, может быть Вы на это просто не наткнулись.


            Сейчас (ну год назад) сам Binder интерфейс и его реализация в Play App и всё остальное работают значительно лучше. Например, информация о продуктах и покупках теперь кешируется на устройстве, что улучшает отзывчивость и стабильность. Этого тоже раньше не было.


            причем в каких то случаях оно до числа пишется, в каких то после

            Дык это как положено в локали пользователя. В некоторых код валюты пишут перед значением:


            https://docs.microsoft.com/en-us/globalization/locale/currency-formatting


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


            1. GavriKos
              18.03.2019 11:31

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


  1. androidovshchik
    18.03.2019 07:21

    То чувство, когда до сих пор пользуешься anjlab библиотекой, а про офф не слышал) Спасибо за информацию!


  1. osipovaleks
    18.03.2019 10:13

    Добавьте ваш аккаунт в Play Console в качестве тестировщика и покупайте только с тестовой карточки.

    Только нужно не забывать, что учётная запись гугла для тестирования должна отличаться от учётной записи разработка (той которая имеет доступ в google play developer console). В документации я не нашел такого описания, но если погуглить мы найдет не одно описание подобных решений вопросов и ответов (например, тут и https://medium.com/bleeding-edge/testing-in-app-purchases-on-android-a6de74f78878), да и по своему опыту я помню, что именно так оно и работало.


    1. osipovaleks
      18.03.2019 18:00

      Так же это указано и в гугловом семпле тут


      Make sure to add your test account (the one you will use to test purchases) to the "testers" section of your app. Your test account CANNOT BE THE SAME AS THE PUBLISHER ACCOUNT. If it is, your purchases won't go through.


    1. habrahabrovec Автор
      19.03.2019 23:30

      Хорошее замечание, спасибо большое.


  1. peacemakerv
    18.03.2019 13:34

    Спасибо за статью! Но еще больше интересно увидеть какой-то пример практической реализации проверки достоверности сделанной покупки с помощью своего сервера (хотя бы PHP). А то при наличии LuckyPatcher и подобного — все эти покупки легко обходятся, насколько я понимаю.

    Я так понимаю, что приложение должно что-то запросить у Гугл API, и запросить свой сервер, чтобы тот что-то запросил у Гугл API. А потом эти два ответа где-то надо сравнить, чтобы убедится в наличии платежа?


    1. Nexus7
      18.03.2019 13:51

      В «покупке» идёт вместе с данными о покупке и цифровая подпись, которую надо проверить:
      public class Purchase {
      private final String mOriginalJson;
      private final String mSignature;

      Ниже я дал ссылку на пример, там есть функции проверки подписи: github.com/googlesamples/android-play-billing/blob/master/TrivialDriveKotlin/app/src/main/java/com/kotlin/trivialdrive/billingrepo/Security.kt

      Надеюсь, перевести его с Kotlin на PHP не составит труда, там обычная проверка подписи с открытым RSA — ключом.


      1. peacemakerv
        18.03.2019 14:00

        Благодарю за инфо, но вот как раз практического полного примера PHP и не хватает.


        1. Nexus7
          18.03.2019 14:20

          Я не силён в PHP, но вот это оно? php.net/manual/ru/function.openssl-verify.php
          Приложение передаёт на сервер originalJson и signature, которые используются в openssl_verify()

          Тут есть ещё одна тонкость, что ответит сервер? Если он на проверку отвечает ok/error, то ничто не помешает в приложении подменить его ответ или убрать эту проверку. В идеале, если приложение после покупки получает доступ к какому-либо контенту, то этот контент должен загружаться с сервера с зашифрованном виде с ключом, который может быть сгенерирован только в этой инсталляции приложения. Если контент слишком большой для загрузки, то сервер может присылать что-то наподобие лицензионного ключа, позволяющего расшифровать контент в приложении.


          1. kmansoft
            20.03.2019 10:16

            Если он на проверку отвечает ok/error, то ничто не помешает в приложении подменить его ответ

            Ну можно например с клиента на сервер отправлять NONCE и чтобы сервер подписывал ответ (в котором будет присутствовать этот NONCE, чтобы не было replay attack). Или использовать https вместе с certificate pinning.


            или убрать эту проверку

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


            Или заменить код который читает из данных приложения "состояние активации" (здесь правда кеширование в Google Play нам на руку — можно запрашивать факт покупки заново, и довольно часто).


            Так что защита от атак на процесс покупки — это хорошо, но есть и другие векторы.


            1. Nexus7
              20.03.2019 12:21

              чтобы сервер подписывал ответ

              Если потом стоит просто if(isSignatureValid) { letsPlay() }
              То
              код проверки и валидации, каким бы утончённым он ни был, можно просто заменить на заглушки.

              Тут в соседней ветке дошли до того, что сервер после валидации покупки должен присылать зашифрованный контент и виртуальную машину для его расшифровывания. ;)

              можно запрашивать факт покупки заново, и довольно часто).

              Если у вас хотя бы сотня тысяч клиентов, то придётся поднимать хорошую серверную инфраструктуру для постоянной валидации покупок, а там довольно медленный RSA. Поэтому я и говорил о кэше покупок в приложении, чтобы валидацию делать как можно реже.


              1. kmansoft
                20.03.2019 12:28

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


                С контентом и ключами да, решение. Но скажем для активации платных функций его применить будет сложнее.


                Но всё-таки мне кажется что среди пользователей желающих искать какие-то там пропатченные версии где-то в интернете — довольно мало в %%. Впрочем цифр у меня нет.


                1. Nexus7
                  20.03.2019 13:57

                  Так пользователи будут искать приложение в том же Google Play. Конкретный пример: позавчера хабравчане выпустили приложение lamptest.ru. Сейчас в поиске Google Play по запросу lamptest отображается три приложения, а может появиться четвёртое, например, lamptest.pro — перепакованная версия lamptest.ru pro со своей рекламой, доходы от которой потекут в карман хакеру Васе. И пользователи даже не будут разбираться, кто там первый что выпустил. Ведь бесплатная про версия гораздо круче платной про версии ;)


                  1. kmansoft
                    20.03.2019 14:02

                    А вот как. Взломанные прямо в Play.


                    По такому можно попробовать бить DMCA. Но тяжело да если хакеров больше и у них сильнее мотивация.


  1. Nexus7
    18.03.2019 13:42

    Не раскрыто две важных темы:
    1. Кэш покупок, чтобы приложение могло работать в оффлайне.
    2. Проверка валидности подписи покупки. Отсюда ещё возникает проблема скрытного хранения ключа проверки этой подписи внутри приложения (что не надёжно) или запуск собственного сервера по проверке этой покупки вне приложения (что рекомендуется Google).
    В общих чертах это разобрано в свежем примере от Google: github.com/googlesamples/android-play-billing/tree/master/TrivialDriveKotlin

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

    Billing-библиотека делает это сама, отдельно не надо ничего добавлять.


    1. habrahabrovec Автор
      19.03.2019 23:14

      Приложение знает о покупках даже после того, как вы очистите данные. Это работает за счет кэша Google Servises, насколько я понимаю.


      1. Nexus7
        20.03.2019 00:40

        Да, покупки приходят потом из кэша Play Services, но если у вас валидация покупки происходит на сервере, то как приложение это сделает в оффлайне? Поэтому надо где-то хранить, что такой-то Order ID проверен. И не в открытом виде ;)