API биллинга Google Play: как понять, что пользователь подписан?

Я хочу узнать, активна ли пользовательская подписка на базовый/премиум-контент или нет из файла MainActivity. Существует класс BillingClientLifecycle, инициирующий процесс подписки. Как я понял, queryPurchses должно показывать, есть у пользователя активная подписка или нет. Но, по-видимому, он показывает (с помощью тостов, которые я разместил там, чтобы показать статус подписки), что пользователь подписан, даже если пользователь на самом деле не подписан.

public void queryPurchases() {
        if (!billingClient.isReady()) {
            Log.e(TAG, "queryPurchases: BillingClient is not ready");
        }
        Log.d(TAG, "queryPurchases: SUBS");
        Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.SUBS);
        if (result == null) {
            Log.i(TAG, "queryPurchases: null purchase result");
            processPurchases(null);
            ///
            Toast.makeText(applicationContext,"queryPurchases: null purchase result", Toast.LENGTH_SHORT).show();
        } else {
            if (result.getPurchasesList() == null) {
                Log.i(TAG, "queryPurchases: null purchase list");
                processPurchases(null);
                ///
                Toast.makeText(applicationContext,"queryPurchases: null purchase list", Toast.LENGTH_SHORT).show();
            } else {
                processPurchases(result.getPurchasesList());
                ///
                Toast.makeText(applicationContext,"user has subscription!", Toast.LENGTH_SHORT).show();
            }
        }
    }

Что я здесь делаю неправильно? Я хочу обновить основную активность в соответствии со статусом подписки. BillingClientLifecycle выглядит следующим образом:

public class BillingClientLifecycle implements LifecycleObserver, PurchasesUpdatedListener,
    BillingClientStateListener, SkuDetailsResponseListener {

private static final String TAG = "BillingLifecycle";

Context applicationContext = MainActivity.getContextOfApplication();

/**
 * The purchase event is observable. Only one observer will be notified.
 */
public SingleLiveEvent<List<Purchase>> purchaseUpdateEvent = new SingleLiveEvent<>();

/**
 * Purchases are observable. This list will be updated when the Billing Library
 * detects new or existing purchases. All observers will be notified.
 */
public MutableLiveData<List<Purchase>> purchases = new MutableLiveData<>();

/**
 * SkuDetails for all known SKUs.
 */
public MutableLiveData<Map<String, SkuDetails>> skusWithSkuDetails = new MutableLiveData<>();

private static volatile BillingClientLifecycle INSTANCE;

private Application app;
private BillingClient billingClient;

public BillingClientLifecycle(Application app) {
    this.app = app;
}

public static BillingClientLifecycle getInstance(Application app) {
    if (INSTANCE == null) {
        synchronized (BillingClientLifecycle.class) {
            if (INSTANCE == null) {
                INSTANCE = new BillingClientLifecycle(app);
            }
        }
    }
    return INSTANCE;
}

@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
public void create() {
    Log.d(TAG, "ON_CREATE");
    // Create a new BillingClient in onCreate().
    // Since the BillingClient can only be used once, we need to create a new instance
    // after ending the previous connection to the Google Play Store in onDestroy().
    billingClient = BillingClient.newBuilder(app)
            .setListener(this)
            .enablePendingPurchases() // Not used for subscriptions.
            .build();
    if (!billingClient.isReady()) {
        Log.d(TAG, "BillingClient: Start connection...");
        billingClient.startConnection(this);
    }
}

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
public void destroy() {
    Log.d(TAG, "ON_DESTROY");
    if (billingClient.isReady()) {
        Log.d(TAG, "BillingClient can only be used once -- closing connection");
        // BillingClient can only be used once.
        // After calling endConnection(), we must create a new BillingClient.
        billingClient.endConnection();
    }
}

@Override
public void onBillingSetupFinished(BillingResult billingResult) {
    int responseCode = billingResult.getResponseCode();
    String debugMessage = billingResult.getDebugMessage();
    Log.d(TAG, "onBillingSetupFinished: " + responseCode + " " + debugMessage);
    if (responseCode == BillingClient.BillingResponseCode.OK) {
        // The billing client is ready. You can query purchases here.
        querySkuDetails();
        queryPurchases();
    }
}

@Override
public void onBillingServiceDisconnected() {
    Log.d(TAG, "onBillingServiceDisconnected");
    // TODO: Try connecting again with exponential backoff.
}

/**
 * Receives the result from {@link #querySkuDetails()}}.
 * <p>
 * Store the SkuDetails and post them in the {@link #skusWithSkuDetails}. This allows other
 * parts of the app to use the {@link SkuDetails} to show SKU information and make purchases.
 */
@Override
public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetailsList) {
    if (billingResult == null) {
        Log.wtf(TAG, "onSkuDetailsResponse: null BillingResult");
        return;
    }

    int responseCode = billingResult.getResponseCode();
    String debugMessage = billingResult.getDebugMessage();
    switch (responseCode) {
        case BillingClient.BillingResponseCode.OK:
            Log.i(TAG, "onSkuDetailsResponse: " + responseCode + " " + debugMessage);
            if (skuDetailsList == null) {
                Log.w(TAG, "onSkuDetailsResponse: null SkuDetails list");
                skusWithSkuDetails.postValue(Collections.<String, SkuDetails>emptyMap());
            } else {
                Map<String, SkuDetails> newSkusDetailList = new HashMap<String, SkuDetails>();
                for (SkuDetails skuDetails : skuDetailsList) {
                    newSkusDetailList.put(skuDetails.getSku(), skuDetails);
                }
                skusWithSkuDetails.postValue(newSkusDetailList);
                Log.i(TAG, "onSkuDetailsResponse: count " + newSkusDetailList.size());
            }
            break;
        case BillingClient.BillingResponseCode.SERVICE_DISCONNECTED:
        case BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE:
        case BillingClient.BillingResponseCode.BILLING_UNAVAILABLE:
        case BillingClient.BillingResponseCode.ITEM_UNAVAILABLE:
        case BillingClient.BillingResponseCode.DEVELOPER_ERROR:
        case BillingClient.BillingResponseCode.ERROR:
            Log.e(TAG, "onSkuDetailsResponse: " + responseCode + " " + debugMessage);
            break;
        case BillingClient.BillingResponseCode.USER_CANCELED:
            Log.i(TAG, "onSkuDetailsResponse: " + responseCode + " " + debugMessage);
            break;
        // These response codes are not expected.
        case BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED:
        case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED:
        case BillingClient.BillingResponseCode.ITEM_NOT_OWNED:
        default:
            Log.wtf(TAG, "onSkuDetailsResponse: " + responseCode + " " + debugMessage);
    }
}

/**
 * Query Google Play Billing for existing purchases.
 * <p>
 * New purchases will be provided to the PurchasesUpdatedListener.
 * You still need to check the Google Play Billing API to know when purchase tokens are removed.
 */
public void queryPurchases() {
    if (!billingClient.isReady()) {
        Log.e(TAG, "queryPurchases: BillingClient is not ready");
    }
    Log.d(TAG, "queryPurchases: SUBS");
    Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.SUBS);
    if (result == null) {
        Log.i(TAG, "queryPurchases: null purchase result");
        processPurchases(null);
        ///
        Toast.makeText(applicationContext,"queryPurchases: null purchase result", Toast.LENGTH_SHORT).show();
    } else {
        if (result.getPurchasesList() == null) {
            Log.i(TAG, "queryPurchases: null purchase list");
            processPurchases(null);
            ///
            Toast.makeText(applicationContext,"queryPurchases: null purchase list", Toast.LENGTH_SHORT).show();
        } else {
            processPurchases(result.getPurchasesList());
            ///
            Toast.makeText(applicationContext,"user has subscription!", Toast.LENGTH_SHORT).show();
        }
    }
}

/**
 * Called by the Billing Library when new purchases are detected.
 */
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
    if (billingResult == null) {
        Log.wtf(TAG, "onPurchasesUpdated: null BillingResult");
        return;
    }
    int responseCode = billingResult.getResponseCode();
    String debugMessage = billingResult.getDebugMessage();
    Log.d(TAG, "onPurchasesUpdated: $responseCode $debugMessage");
    switch (responseCode) {
        case BillingClient.BillingResponseCode.OK:
            if (purchases == null) {
                Log.d(TAG, "onPurchasesUpdated: null purchase list");
                processPurchases(null);
            } else {
                processPurchases(purchases);
            }
            break;
        case BillingClient.BillingResponseCode.USER_CANCELED:
            Log.i(TAG, "onPurchasesUpdated: User canceled the purchase");
            break;
        case BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED:
            Log.i(TAG, "onPurchasesUpdated: The user already owns this item");
            break;
        case BillingClient.BillingResponseCode.DEVELOPER_ERROR:
            Log.e(TAG, "onPurchasesUpdated: Developer error means that Google Play " +
                    "does not recognize the configuration. If you are just getting started, " +
                    "make sure you have configured the application correctly in the " +
                    "Google Play Console. The SKU product ID must match and the APK you " +
                    "are using must be signed with release keys."
            );
            break;
    }
}

/**
 * Send purchase SingleLiveEvent and update purchases LiveData.
 * <p>
 * The SingleLiveEvent will trigger network call to verify the subscriptions on the sever.
 * The LiveData will allow Google Play settings UI to update based on the latest purchase data.
 */
private void processPurchases(List<Purchase> purchasesList) {
    if (purchasesList != null) {
        Log.d(TAG, "processPurchases: " + purchasesList.size() + " purchase(s)");
    } else {
        Log.d(TAG, "processPurchases: with no purchases");
    }
    if (isUnchangedPurchaseList(purchasesList)) {
        Log.d(TAG, "processPurchases: Purchase list has not changed");
        return;
    }
    purchaseUpdateEvent.postValue(purchasesList);
    purchases.postValue(purchasesList);
    if (purchasesList != null) {
        logAcknowledgementStatus(purchasesList);
    }
}

/**
 * Log the number of purchases that are acknowledge and not acknowledged.
 * <p>
 * https://developer.android.com/google/play/billing/billing_library_releases_notes#2_0_acknowledge
 * <p>
 * When the purchase is first received, it will not be acknowledge.
 * This application sends the purchase token to the server for registration. After the
 * purchase token is registered to an account, the Android app acknowledges the purchase token.
 * The next time the purchase list is updated, it will contain acknowledged purchases.
 */
private void logAcknowledgementStatus(List<Purchase> purchasesList) {
    int ack_yes = 0;
    int ack_no = 0;
    for (Purchase purchase : purchasesList) {
        if (purchase.isAcknowledged()) {
            ack_yes++;
        } else {
            ack_no++;
        }
    }
    Log.d(TAG, "logAcknowledgementStatus: acknowledged=" + ack_yes +
            " unacknowledged=" + ack_no);
}

/**
 * Check whether the purchases have changed before posting changes.
 */
private boolean isUnchangedPurchaseList(List<Purchase> purchasesList) {
    // TODO: Optimize to avoid updates with identical data.
    return false;
}

/**
 * In order to make purchases, you need the {@link SkuDetails} for the item or subscription.
 * This is an asynchronous call that will receive a result in {@link #onSkuDetailsResponse}.
 */
public void querySkuDetails() {
    Log.d(TAG, "querySkuDetails");

    List<String> skus = new ArrayList<>();
    skus.add(Constants.BASIC_SKU);
    skus.add(Constants.PREMIUM_SKU);

    SkuDetailsParams params = SkuDetailsParams.newBuilder()
            .setType(BillingClient.SkuType.SUBS)
            .setSkusList(skus)
            .build();

    Log.i(TAG, "querySkuDetailsAsync");
    billingClient.querySkuDetailsAsync(params, this);
}

/**
 * Launching the billing flow.
 * <p>
 * Launching the UI to make a purchase requires a reference to the Activity.
 */
public int launchBillingFlow(Activity activity, BillingFlowParams params) {
    String sku = params.getSku();
    String oldSku = params.getOldSku();
    Log.i(TAG, "launchBillingFlow: sku: " + sku + ", oldSku: " + oldSku);
    if (!billingClient.isReady()) {
        Log.e(TAG, "launchBillingFlow: BillingClient is not ready");
    }
    BillingResult billingResult = billingClient.launchBillingFlow(activity, params);
    int responseCode = billingResult.getResponseCode();
    String debugMessage = billingResult.getDebugMessage();
    Log.d(TAG, "launchBillingFlow: BillingResponse " + responseCode + " " + debugMessage);
    return responseCode;
}

/**
 * Acknowledge a purchase.
 * <p>
 * https://developer.android.com/google/play/billing/billing_library_releases_notes#2_0_acknowledge
 * <p>
 * Apps should acknowledge the purchase after confirming that the purchase token
 * has been associated with a user. This app only acknowledges purchases after
 * successfully receiving the subscription data back from the server.
 * <p>
 * Developers can choose to acknowledge purchases from a server using the
 * Google Play Developer API. The server has direct access to the user database,
 * so using the Google Play Developer API for acknowledgement might be more reliable.
 * TODO(134506821): Acknowledge purchases on the server.
 * <p>
 * If the purchase token is not acknowledged within 3 days,
 * then Google Play will automatically refund and revoke the purchase.
 * This behavior helps ensure that users are not charged for subscriptions unless the
 * user has successfully received access to the content.
 * This eliminates a category of issues where users complain to developers
 * that they paid for something that the app is not giving to them.
 */
public void acknowledgePurchase(String purchaseToken) {
    Log.d(TAG, "acknowledgePurchase");
    AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchaseToken)
            .build();
    billingClient.acknowledgePurchase(params, new AcknowledgePurchaseResponseListener() {
        @Override
        public void onAcknowledgePurchaseResponse(BillingResult billingResult) {
            int responseCode = billingResult.getResponseCode();
            String debugMessage = billingResult.getDebugMessage();
            Log.d(TAG, "acknowledgePurchase: " + responseCode + " " + debugMessage);
        }
    });
}

}

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




Ответы (2)


Реализация процесса выставления счетов выглядит хорошо, но отсутствует проверка, чтобы определить, действительно ли подписка активна в текущий момент.

Наблюдение может осуществляться с помощью объектов LiveData. Так что нам не нужны SharedPreferences или около того для хранения состояния. Я расскажу об этом в части наблюдения ниже. Подробный ответ:


Список покупок

Давайте сначала объясним, что означает этот список покупок в биллинговом API:

  1. Это список всех покупок пользователя в приложении или подписки.
  2. Эти покупки должны быть подтверждены либо приложением, либо бэкэндом (рекомендуется через бэкэнд, но оба варианта возможны)
  3. Этот список покупок включает платежи, которые еще ожидаются, а также платежи, которые еще не подтверждены.

Увидев выполнение шага подтверждения, я предполагаю, что подтверждение платежа выполнено успешно.

Пункт 3 заключается в том, почему он не определяет фактическое состояние подписки, поскольку состояние покупок не проверяется.


Проверка состояния подписки

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

Каждая покупка имеет некоторые дополнительные данные. Вот методы, которые нам нужны для проверки состояния:

  • getSku() // Чтобы убедиться, что продукт соответствует нашим требованиям
  • getPurchaseState() // Чтобы получить актуальный статус покупки
  • isAcknowledged() // Чтобы узнать, подтвержден ли платеж, если нет, это означает, что платеж еще не прошел

Чтобы проверить, является ли в настоящее время покупка оплаченной и активной для продукта PREMIUM:

boolean isPremiumActive = Constants.PREMIUM_SKU.equals(purchase.getSku()) && purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged()

Если мы хотим проверить, активна ли какая-либо из подписок, мы проверяем другие sku на то же самое (перебирая sku и покупки)

* Обратите внимание, что теперь, если isPremiumActive равно true, это означает, что у пользователя в настоящее время есть активная подписка. Это означает, что если пользователь отменил свою подписку, но все еще оплатил до окончания периода, это значение все равно будет истинным. Просто потому, что у пользователя по-прежнему есть право доступа к контенту до истечения расчетного периода.

* В случае, если период подписки действительно закончился (отменен или истек), биллинговый клиент больше не будет возвращать покупку.


Наблюдение за текущим статусом

Теперь, когда мы знаем, как проверять покупки, мы можем легко прочитать это состояние с помощью LiveData, чтобы мы могли получить к нему доступ в любое время. В примере у нас уже есть LiveData purchases, он содержит все покупки и заполняется после вызова queryPurchases().

  1. Создание LiveData

Давайте создадим новый LiveData, который использует этот purchases LiveData, но вместо этого будет возвращать true или false в зависимости от того, активен ли у нас PREMIUM_SKU:

public LiveData<Boolean> isSubscriptionActive = Transformations.map(purchases, purchases -> {
    boolean hasSubscription = false;
    for (Purchase purchase : purchases) {
        // TODO: Also check for the other SKU's if needed
        if (Constants.PREMIUM_SKU.equals(purchase.getSku()) && purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged()) {
            // This purchase is purchased and acknowledged, it is currently active!
            hasSubscription = true;
        }
    }
    return hasSubscription;
});

Добавьте этот блок в BillingClientLifecycle, он выдаст значение true или false, если список покупок изменится

  1. Наблюдая за этим

Как обычно, наблюдайте за этими LiveData в действии, в котором вы хотите получить обновление:

billingClientLifecycle.isSubscriptionActive.observe(this, hasSubscription -> {
    if (hasSubscription) {
        // User is subscribed!
        Toast.makeText(this, "User has subscription!", Toast.LENGTH_SHORT).show();
    } else {
        // User is a regular user!
    }
});

Поместите это в MainActivity вашего дела. Он будет следить за изменениями подписки и запускать одну из двух функций при изменении.

* Если нужны не оперативные данные, а прямой способ получения значения, вы также можете просто использовать логическое поле внутри billingClientLifecycle и правильно обновить его в методе processPurchases() с той же проверкой, что и выше.


Дополнительно

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

Если покупка имеет статус Purchase.PurchaseState.PENDING, это означает, что Google или Пользователю еще предстоит выполнить некоторые действия для проверки платежа. В основном это означает, что биллинговый API не уверен, был ли платеж выполнен, если это произойдет. Мы могли бы также информировать пользователя об этом состоянии, например, показывая сообщение для выполнения его платежа или около того.

Если покупка оплачена, но еще не подтверждена, это означает, что шаг подтверждения в BillingClientLifecycle не был успешным. Кроме того, в этом случае Google Play автоматически вернет платеж пользователю. Например: для месячных подписок период подтверждения составляет 3 дня, поэтому через 3 дня пользователю возвращаются деньги, а покупка удаляется.

person Alex    schedule 01.07.2020
comment
Спасибо, Алекс. Я получаю NPE isSubscriptionActive' on a null object reference - person Namikaze Minato; 02.07.2020
comment
Без проблем! Этот NPE должен быть потому, что ваш billingClientLifecycle равен null тогда, когда вы его вызываете. Пожалуйста, вызовите наблюдателя после инициализации billingClientLifecycle. Или иначе: можете ли вы также поделиться частью MainActivity, где вы ее создаете, и где вы связываете MainActivity с billingClient? - person Alex; 02.07.2020
comment
Я добавил MainActivity - person Namikaze Minato; 02.07.2020
comment
Большое спасибо! Переместите строки billingClientLifecycle.isSubscriptionActive.observe на строку ниже getLifecycle().addObserver(billingClientLifecycle);. Это должно исправить это - person Alex; 02.07.2020
comment
Он показывает тост. Но всегда, поскольку у пользователя нет подписок, даже если пользователь недавно приобрел подписку. - person Namikaze Minato; 02.07.2020
comment
Я принял ваш ответ, так как думаю, что это решение должно работать, и оно мне очень помогло. Хотя мне нужно выяснить, что не так с моим кодом, который всегда возвращает неподписанный. Спасибо, Алекс. - person Namikaze Minato; 02.07.2020
comment
Добро пожаловать! Пожалуйста, проверьте, какое условие утверждения неверно, если оно не работает. Если isAcknowledged ложно, это означает, что шаг подтверждения еще не выполнен. -- Кроме того, пожалуйста, обновите вопрос до более ранней версии, в которой вопрос четко описан, так как это поможет другим пользователям столкнуться с теми же проблемами с биллинговым клиентом. - person Alex; 02.07.2020
comment
Большое спасибо, Алекс. Я нашел проблему. Как и ожидалось, покупки не были подтверждены, и все они были возвращены. Теперь он работает отлично мой друг! Отличная работа. - person Namikaze Minato; 03.07.2020
comment
Хорошо, спасибо за проверку! Приятно слышать, что теперь это работает - person Alex; 03.07.2020
comment
Мой друг, не могли бы вы обновить свое сообщение, а также добавить прямой способ получения подписки? Как видите, этот вопрос будет популярным. - person Namikaze Minato; 29.08.2020
comment
Я не думаю, что этот ответ правильный. Проверка возвращаемого результата из queryPurchases() ненадежна как в соответствии с документацией, так и из моего собственного опыта. - person brian_wang; 11.09.2020
comment
Прежде всего, в документации упоминается, что API использует кеш приложения Google Play Store без инициирования сетевого запроса. поэтому не следует использовать его как простой источник для подтверждения покупки: developer .android.com/reference/com/android/billingclient/api/ - person brian_wang; 11.09.2020
comment
По моему собственному опыту, я обнаружил 2 проблемы: Состояние покупки, которое я получил в возвращаемой информации от queryPurchases(), — UNSPECIFIED_STATE, хотя покупка действительна. И если ваш пользователь устанавливает приложение на несколько устройств, есть вероятность, что некоторые устройства могут не получать самую последнюю информацию из API из-за локального кэширования. - person brian_wang; 11.09.2020

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

https://github.com/anjlab/android-inapp-billing-v3

интерфейс BillingProcessor.IBillingHandler для реализации в вашей основной деятельности

private lateinit var mBillingProcessor: BillingProcessor
val PRODUCT_ID = "remove_ads"//original, set as you want
//val PRODUCT_ID = "android.test.purchased"//testing for purchase 
//val PRODUCT_ID = "android.test.canceled"//testing for cancel purchase

в методе onCreate()

mBillingProcessor = BillingProcessor(this, "your_license_key", this)
mBillingProcessor.initialize()
//Here after initialization you can check subscription by
if(mBillingProcessor.isSubscribed(PRODUCT_ID)){
   //user has Subscribed
}else{
  //user has not Subscribed
}

когда пользователь нажимает для подписки

mBillingProcessor.subscribe(this, PRODUCT_ID)

реализовать этот метод onActivityResult

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (!mBillingProcessor.handleActivityResult(requestCode, resultCode, data)) {
        super.onActivityResult(requestCode, resultCode, data)
    }
    super.onActivityResult(requestCode, resultCode, data)
}

переопределить метод этой библиотеки

override fun onProductPurchased(productId: String, details: TransactionDetails?) {
        if (mBillingProcessor.isPurchased(PRODUCT_ID).toString() == "true") {
           //here when user purchased successfully
        }
}

этот метод возвращает вам логическую переменную

person Abdur Rehman    schedule 02.07.2020
comment
Спасибо. Он просто использует автономную базу данных? Или внутренний сервер? - person Namikaze Minato; 02.07.2020
comment
На самом деле он использует библиотеку com.android.billingclient:billing, это просто оболочка библиотеки биллинга игр, у него нет внутреннего сервера, он использует библиотеку биллинга google play. - person Abdur Rehman; 02.07.2020