Android In-app Billing: від мобільного додатку до серверної валідації і тестування

image

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

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

Для початку коротко про пацієнта. Я буду розбирати по шматочках Google Play In-App Billing V3 а також хмарний Чоловічий 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 знайдіть і розкрийте секцію Додатково.
Виберіть 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(type String, 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.99 USD"
product.price = jsonObject.getString("price");
// "true/false"
product.isSubscription = jsonObject.getString("type").equals("subs");
// "990000" = ціна 1000000 x
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 → Установки → User Accounts & Rights і вибираємо Invite new user.

Заповнюємо дані.

image

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

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

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

Як ви будете зберігати 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-сервісу, який повинен працювати без втручання людини.

Я сподіваюся, що цією статтею допоможу комусь заощадити трохи часу і нервів.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.