Библиотека для совершения покупок внутри приложений (Android In-App Billing v.3)


    Checkout («касса», «кассовый аппарат») — это библиотека для совершения покупок внутри приложений на базе Android In-App Billing v.3. Основная цель — уменьшить время разработчика, затрачиваемое на внедрение платежей в Андроид приложения. Проект был вдохновлён библиотекой Volley, и проектировался для того, чтобы быть максимально простым в использовании, быстрым и гибким.


    Подготовка


    Существует несколько способов подключить библиотеку в проект:
    • Загрузить исходный код из github репозитория и скопировать его в свой проект
    • Для пользователей Maven использовать следующую зависимость:
      <dependency>
          <groupId>org.solovyev.android</groupId>
          <artifactId>checkout</artifactId>
          <version>x.x.x</version>
      </dependency>
      
    • Для пользователей Gradle использовать следующую зависимость:
      compile 'org.solovyev.android:checkout:x.x.x@aar'
      
    • Загрузить нужный архив из репозитория


    Библиотека требует com.android.vending.BILLING разрешение (permission).
    Если вы подключили библиотеку как зависимость (например, в Maven или Gradle), то дополнительно делать ничего не надо. В противном случае, нужно добавить следующую строчку в AndroidManifest.xml:
    <uses-permission android:name="com.android.vending.BILLING" />


    Использование


    Библиотека содержит 3 основных класса: Billing, Checkout и Inventory.

    Класс Billing обрабатывает запросы на покупку (см. методы IInAppBillingService.aidl) и управляет подкючением к сервису Google Play. Этот класс лучше всего использовать как синглтон, для того чтобы все запросы выстраивались в одну очередь и использовали один кеш. Например, класс приложения может выглядеть следующим образом:

    public class MyApplication extends Application {
        /**
         * For better performance billing class should be used as singleton
         */
        @Nonnull
        private final Billing billing = new Billing(this, new Billing.Configuration() {
            @Nonnull
            @Override
            public String getPublicKey() {
                return "Your public key, don't forget to encrypt it somehow";
            }
    
            @Nullable
            @Override
            public Cache getCache() {
                return Billing.newCache();
            }
        });
    
        /**
         * Application wide {@link org.solovyev.android.checkout.Checkout} instance (can be used anywhere in the app).
         * This instance contains all available products in the app.
         */
        @Nonnull
        private final Checkout checkout = Checkout.forApplication(billing, Products.create().add(IN_APP, asList("product")));
    
        @Nonnull
        private static MyApplication instance;
    
        public MyApplication() {
            instance = this;
        }
    
        @Override
        public void onCreate() {
            super.onCreate();
            billing.connect();
        }
    
        public static MyApplication get() {
            return instance;
        }
    
        @Nonnull
        public Checkout getCheckout() {
            return checkout;
        }
    
        //...
    }
    

    Класс Billing можно использовать для выполнения запросов напрямую, но чаще удобнее использовать посредника — класс Checkout. Последний добавляет к каждому запросу тег, по которому запрос может быть отменён, что может быть полезным, например, в Activity. Checkout позволяет загрузить текущее состояние покупок через метод Checkout#loadInventory(). Также Checkout, а точнее его наследник ActivityCheckout, предоставляет доступ к PurchaseFlow, который в свою очередь осуществляет действия, нужные для покупки. Код класса Activity, в котором отображается список покупок, и который позволяет совершать покупки, представлен ниже:

    public class MyActivity extends Activity {
        @Nonnull
        private final ActivityCheckout checkout = Checkout.forActivity(this, MyApplication.get().getCheckout());
    
        @Nonnull
        private Inventory inventory;
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            checkout.start();
            // you only need this if this activity starts purchase process
            checkout.createPurchaseFlow(new PurchaseListener());
            // you only need this if this activity needs information about purchases/SKUs
            inventory = checkout.loadInventory();
            inventory.whenLoaded(new InventoryLoadedListener())
        }
    
        @Override
        protected void onActivityResult(int requestCode, int resultCode, Intent data) {
            super.onActivityResult(requestCode, resultCode, data);
            checkout.onActivityResult(requestCode, resultCode, data);
        }
    
        @Override
        protected void onDestroy() {
            checkout.stop();
            super.onDestroy();
        }
    
        //...
    }
    


    Исходный код, примеры


    Исходный код доступен в моём репозитории на github. Там же вы найдёте исходный код тестового приложения (само приложение может быть установлено из Google Play). Всё под лицензией Apache License, Version 2.0.

    Заключение


    В библиотеке есть над чем работать (например, не хватает покрытия тестами, и было бы неплохо иметь процедуру миграции из одной популярной библиотеки). Но в целом, ей уже можно пользоваться в продакшене, что я и делаю в своём приложении Say it right!.
    Вопросы и пожелания приветствуются в комментариях к статье, а также в багтрекере на гитхабе.

    UPD Для библиотеки есть тестовое приложение в Google Play. Для того чтобы совершать тестовые платежи нужно вступить в эту Google+ группу.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 22
    • –2
      Не в обиду будет сказано, но — ох уж эти велосипеды. Лучше бы github.com/onepf/OpenIAB поддержали, баги там пофиксили.
      Всегда удивляет что вместо того чтобы хотя бы попробовать доработать уже готовый, но может, не совсем доделанный велосипед все пишут свой.
      • +3
        Не в обиду будет сказано, но:
        1. Это не эффектиный и даже неправильно работающий код — новый поток создаётся каждый раз, `flagEndAsync` находится не в finally блоке, доступ к `setupState` не синхронизован:
              (new Thread(new Runnable() {
                    public void run() {
                        final List<IabResult> results = new ArrayList<IabResult>();
                        for (Purchase purchase : purchases) {
                            try {
                                consume(purchase);
                                results.add(new IabResult(BILLING_RESPONSE_RESULT_OK, "Successful consume of sku " + purchase.getSku()));
                            } catch (IabException ex) {
                                results.add(ex.getResult());
                            }
                        }
        
                        flagEndAsync();
                        if (setupState != SETUP_DISPOSED && singleListener != null) {
                            notifyHandler.post(new Runnable() {
                                public void run() {
                                    singleListener.onConsumeFinished(purchases.get(0), results.get(0));
                                }
                            });
                        }
                        if (setupState != SETUP_DISPOSED && multiListener != null) {
                            notifyHandler.post(new Runnable() {
                                public void run() {
                                    multiListener.onConsumeMultiFinished(purchases, results);
                                }
                            });
                        }
                    }
                })).start();
        

        2. Судя по всему, OpenIAB не работает в многопоточном окружении (Billing из моей библиотеки thread safe)
        3. Использование переменных `mAsyncInProgress` и `mAsyncOperation` без синхронизации, вы серьёзно?
        4. Где тесты?
        И это только при беглом осмотре одного класса, который, кстати, несколько великоват для его нормальной поддержки (~2k строк кода).

        Подводя итог, я бы не стал использовать OpenAIB в своих проектах, так как качество кода оставляет желать лучшего.
        • –1
          Так я о том и говорю, возьмите и улучшите. Зачем увеличивать количество одинаковых по функционалу проектов? Чтобы потом те кто хочет этими библиотеками воспользоваться увидели этот зоопарк и у них глаза разбегались? К сожалению, практика показывает что все такие проекты начинаются очень бодро и хорошо, а потом пыл угас, в поддержку закапываться не хочется и выходит что есть пара десятков разных библиотек, каждая из которых брошена на каком-то этапе разработки.

          В целом, я с Вами согласен код в OpenIAB не супер, но он ведь поддается рефакторингу, да и тесты можно написать.
          • +1
            Я с вами согласен — помогать проектам надо, свои велосипеды обычно не нужны. Но в данном конкретном случае слишком много различий, например, абсолютно разный API. Вы же не хотите, чтобы у всех кто используют старый API поломались сборки после обновления?

            Про поддержку проекта — библиотека работает уже сейчас, расширяться особо нечему. Нужно только будет фиксить баги, но в этом я заинтересован сам, так как использую библиотеку в своих приложениях.
      • 0
        А стандартные Google Service библиотека чем не устраивает?
        По моим впечатлениям, объем кода для интеграции примерно такой же.
        • +3
          Что за стандартная Google Service библиотека? Код из семплов?

          Вот список того, что библиотека берёт на себя:
          1. Асинхронное подключение IInAppBillingService. Если на момент выполнения биллинг запроса сервис не подключён — запрос встаёт в очередь и ждёт подключения.
          2. Многопоточность — некоторые запросы должны выполняться на отдельном потоке, например, getSkuDetails или consumePurchase (см. документацию).
          3. Проверка на поддержку биллинга.
          4. Типовые задачи — парсинг json, обработка ошибок, действия нужные для покупки и т.д.
          5. Отмена запросов (и слушателей). Полезно, например, при закрытии Activity.
          6. Возможность выполнять запросы не из Activity.
          7. Кеширование результатов.
          8. Код Activity становится проще за счёт того, что код биллинга находится в отдельных классах.
        • 0
          А вы смотрели в сторону android-inapp-billing-v3? Не могли бы вы пояснить, чем ваше решение лучше?
          • 0
            Да, проглядел код этой библиотеки перед написанием своей. Есть ряд проблем, например:
            1. BillingProcessor работает только с Activity. Т.е. загрузить данные о покупках, например, в Application или Service просто невозможно.
            2. Каждый раз при старте Activity происходит переподключение биллинг сервиса, что не эффективно, т.к. Activity убивается каждый раз при повороте экрана (у меня он подключается один раз при старте приложения).
            3. BillingProcessor может вызвать методы слушателя тогда, когда это не нужно. Самый простой пример — нужно загрузить список продуктов или купить продукт в Activity. Если пользователь повернёт экран, то Activity убивается, но слушатель, который был создан остаётся. При вызове методов последнего скорее всего приложение навернётся, т.к. Activity уже разрушено.
            4. consumePurchase и другие методы следует вызывать на отдельном потоке (см. доки). В этой библиотеки они вызываются на основном потоке, что может привести к неотзывчивому интерфейсу.
            5. Нет юнит тестов. Для библиотеки по работе с деньгами считаю это неприемлемым.

            В целом код невысокого качества — это заметно даже после беглого осмотра (форматирование, доки, название переменных и т.д.)
          • 0
            Спасибо за ответ! Сейчас как раз просматриваю библиотеки для реализации биллинга, очень странно, что практически нет готовых решений приемлимого качества, а имплементить самостоятельно не очень хочеться.
            • 0
              А с помощью Вашего приложения можно продавать подписки с пробным периодом?
              • 0
                Ругается на javax.annotation
                • 0
                  Добавил annotations.jar из FindBugs в libs, ошибки ушли. Магия
                  • 0
                    Тоже промучился с настройками студии, пытаясь через них подключить javax.annotation.
                    Решение лежит в исходнике в конфигурации lib/build.gradle. Только там указана старая версия — «2.0.3». На сейчас последняя «3.0.1».

                    dependencies {
                    	...
                    	compile 'com.google.code.findbugs:jsr305:3.0.1'
                    }
                    


                    Плюс у меня ругнулось на отсутствие DEBUG_MODE. Тоже добавил в build.gradle:

                    buildTypes {
                    	debug {
                    		buildConfigField "Boolean", "DEBUG_MODE", "true"
                    	}
                    	release {
                    		buildConfigField "Boolean", "DEBUG_MODE", "false"
                    	}
                    }
                    
                    • 0
                      Можно было просто добавить transitive = true для зависимости
                      • 0
                        Grandle для меня сейчас лес густой. Речь о прописанной старой версии findbugs в зависимостях/dependencies?

                        Вот такая конструкция c «transitive = true» загрузила всё равно 2.0.3, а не последнюю — 3.0.1.

                        compile ('com.google.code.findbugs:jsr305:2.0.3') {
                        	transitive = true
                        }
                        
                        • 0
                          Нет, о библиотечной зависимости:
                          compile ('org.solovyev.android:checkout:x.x.x@aar') {
                              transitive = true
                          }
                          

                          Всё дело в том, что если в Gradle у зависимости указать классификатор (classifier, aar в данном случае), то свойство «transitive» будет выставлено в false, т.е. зависимости зависимостей не подтянутся автоматически.
                  • 0
                    А не подскажете как воспользоваться вашей библиотекой в виджете?
                    Сейчас я на кнопку виджета «купи меня» вешаю
                    intent который запускает пустую activity с
                    кодом покупки и получением информации что покупка
                    была совершена через inventory.
                    (вызовы вашей библиотеки по примеру для activity).

                    Но как-то это не достаточно красиво получается.
                    • 0
                      Это, похоже, единственный способ совершить покупку из виджета. Если вы не хотите, чтобы пользователь переходил на дополнительный экран, можете сделать активити прозрачной
                      • 0
                        Спасибо за ответ и за саму библиотеку, да, activity естественно прозрачная.
                    • 0
                      Обновил в работающем проекте play-services с 10.0.1 на 10.2.0. Обработчик загрузки инвентаря onLoaded перестал вызываться. Checkout.start отрабатывает нормально. Библиотека полугодовалой давности.

                      Вот так было и работало:
                      mCheckout.start();
                      
                      mCheckout.createPurchaseFlow(new PurchaseListener());
                      
                      mInventory = mCheckout.loadInventory();
                      mInventory.whenLoaded(new InventoryLoadedListener());
                      


                      Посмотрел на гитхабе как сейчас предлагаете делать:
                      mCheckout.start();
                      
                      mCheckout.createPurchaseFlow(new PurchaseListener());
                      
                      mInventory = mCheckout.makeInventory();
                      mInventory.load(Inventory.Request.create()
                              .loadAllPurchases()
                              .loadSkus(ProductTypes.IN_APP, "sku_01"), new InventoryCallback());
                      


                      А в статье на медиуме даёте ещё один способ (смесь двух первых), там почему-то нет createPurchaseFlow. Есть startPurchaseFlow в покупке. И непонятно, — необходимо создавать поток сразу после старта (как на гитхабе) или нет (как в новой статье).

                      Честно говоря, десяток способов сделать одно и тоже только запутывают.

                      Не нравится эта тишина с InventoryLoadedListener. Если бы вываливалось с ошибкой, можно было бы найти что не так. Без отладки (приложения для теста платежей теперь надо заливать в плей и без включенной отладки) и на логах Stetho найти проблему самому не получается.
                      • 0
                        Вот шаблон кода, работающего сейчас с 10.2.0.
                        шаблон покупки
                        import android.app.Activity;
                        import android.content.Intent;
                        import android.os.Bundle;
                        import android.support.annotation.Nullable;
                        import android.util.Log;
                        
                        import org.solovyev.android.checkout.ActivityCheckout;
                        import org.solovyev.android.checkout.BillingRequests;
                        import org.solovyev.android.checkout.Checkout;
                        import org.solovyev.android.checkout.EmptyRequestListener;
                        import org.solovyev.android.checkout.Inventory;
                        import org.solovyev.android.checkout.ProductTypes;
                        import org.solovyev.android.checkout.Purchase;
                        import org.solovyev.android.checkout.RequestListener;
                        import org.solovyev.android.checkout.ResponseCodes;
                        import org.solovyev.android.checkout.Sku;
                        
                        import java.util.Locale;
                        
                        import javax.annotation.Nonnull;
                        
                        
                        public abstract class InAppTemplateActivity extends Activity {
                        
                        	public final static String SKU = "sku_1";
                        
                        	private Inventory.Request mInventoryRequest;
                        
                        	private final InventoryCallback mInventoryCallback = new InventoryCallback();
                        
                        	private ActivityCheckout mCheckout;
                        
                        	private Inventory mInventory;
                        
                        
                        	@Override
                        	protected void onCreate(@Nullable Bundle savedInstanceState) {
                        		super.onCreate(savedInstanceState);
                        
                        
                        		mCheckout = Checkout.forActivity(this, application.inApp.getBilling());
                        
                        		mCheckout.start(new Checkout.Listener() {
                        			public void onReady(@Nonnull BillingRequests requests) {
                        				Log.i(this.getClass().toString(), "Checkout onReady");
                        			}
                        
                        			public void onReady(@Nonnull BillingRequests requests, @Nonnull String product, boolean billingSupported) {
                        			}
                        		});
                        
                        		mCheckout.createPurchaseFlow(new PurchaseListener());
                        
                        		mInventory = mCheckout.makeInventory();
                        
                        
                        		mInventoryRequest = Inventory.Request.create();
                        		// load purchase info
                        		mInventoryRequest.loadAllPurchases();
                        		// load SKU details
                        		mInventoryRequest.loadSkus(ProductTypes.IN_APP, "android.test.purchased", SKU);
                        
                        
                        		reloadInventory();
                        	}
                        
                        	@Override
                        	protected void onDestroy() {
                        		mCheckout.stop();
                        		mCheckout = null;
                        
                        		mInventory = null;
                        
                        		super.onDestroy();
                        	}
                        
                        	@Override
                        	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
                        		mCheckout.onActivityResult(requestCode, resultCode, data);
                        		super.onActivityResult(requestCode, resultCode, data);
                        	}
                        
                        	protected void purchase(final String sku) {
                        		mCheckout.whenReady(new Checkout.EmptyListener() {
                        			@Override
                        			public void onReady(BillingRequests requests) {
                        				requests.purchase(ProductTypes.IN_APP, sku, null, mCheckout.getPurchaseFlow());
                        			}
                        		});
                        	}
                        
                        	private void reloadInventory() {
                        		mInventory.load(mInventoryRequest, mInventoryCallback);
                        	}
                        
                        	private void consume(final Purchase purchase) {
                        		mCheckout.whenReady(new Checkout.EmptyListener() {
                        			@Override
                        			public void onReady(@Nonnull BillingRequests requests) {
                        				requests.consume(purchase.token, new RequestListener<Object>() {
                        					@Override
                        					public void onSuccess(@Nonnull Object result) {
                        						Log.i(this.getClass().toString(), "Consume onSuccess");
                        
                        						consumed();
                        
                        						reloadInventory();
                        					}
                        
                        					@Override
                        					public void onError(int response, @Nonnull Exception e) {
                        						// it is possible that our data is not synchronized with data on Google Play => need to handle some errors
                        						if (response == ResponseCodes.ITEM_NOT_OWNED) {
                        							Log.i(this.getClass().toString(), "ERROR: ITEM_NOT_OWNED");
                        
                        							consumed();
                        						} else {
                        							Log.e(this.getClass().toString(), "ERROR: " + e.toString());
                        						}
                        
                        						reloadInventory();
                        					}
                        
                        					private void consumed() {
                        						// если нужно, сделать что-то после потребления покупки
                        					}
                        				});
                        			}
                        		});
                        	}
                        
                        	// Чтобы в оплате проходили Static Responses (у них нет токена) нужно заменить дефолтный PurchaseVerifier на свой,
                        	// например, такой:
                        /*	class MyPurchaseVerifier implements PurchaseVerifier {
                        
                        		@Nonnull
                        		private final String mPublicKey;
                        
                        		public MyPurchaseVerifier(@Nonnull String publicKey) {
                        			mPublicKey = publicKey;
                        		}
                        
                        		@Override
                        		public void verify(@Nonnull List<Purchase> purchases, @Nonnull RequestListener<List<Purchase>> listener) {
                        			final List<Purchase> verifiedPurchases = new ArrayList<Purchase>(purchases.size());
                        			for (Purchase purchase : purchases) {
                        				if ("android.test.purchased;android.test.canceled;android.test.refunded;android.test.item_unavailable".contains(purchase.sku)) {
                        					verifiedPurchases.add(purchase);
                        				} else if (Security.verifyPurchase(mPublicKey, purchase.data, purchase.signature)) {
                        					verifiedPurchases.add(purchase);
                        				} else {
                        					if (isEmpty(purchase.signature)) {
                        						Log.e(this.getClass().toString(), "Cannot verify purchase: " + purchase + ". Signature is empty");
                        					} else {
                        						Log.e(this.getClass().toString(), "Cannot verify purchase: " + purchase + ". Wrong signature");
                        					}
                        				}
                        			}
                        			listener.onSuccess(verifiedPurchases);
                        		}
                        	}*/
                        	// И вернуть его в new Billing.DefaultConfiguration() 
                        	/*
                        	@Nonnull
                        	@Override
                        	public PurchaseVerifier getPurchaseVerifier() {
                        		return new MyPurchaseVerifier(getPublicKey());
                        	}
                        	*/
                        
                        	private class PurchaseListener extends EmptyRequestListener<Purchase> {
                        		@Override
                        		public void onSuccess(@Nonnull Purchase purchase) {
                        			Log.i(this.getClass().toString(), "Purchase onSuccess");
                        
                        			purchased();
                        
                        			reloadInventory();
                        		}
                        
                        		@Override
                        		public void onError(int response, @Nonnull Exception e) {
                        			// it is possible that our data is not synchronized with data on Google Play => need to handle some errors
                        			if (response == ResponseCodes.ITEM_ALREADY_OWNED) {
                        				Log.i(this.getClass().toString(), "ERROR: ITEM_ALREADY_OWNED");
                        
                        				purchased();
                        			} else {
                        				Log.e(this.getClass().toString(), "ERROR: " + e.toString());
                        			}
                        
                        			reloadInventory();
                        		}
                        
                        		private void purchased() {
                        			// сделать что-то после покупки
                        		}
                        	}
                        
                        	private class InventoryCallback implements Inventory.Callback {
                        		@Override
                        		public void onLoaded(Inventory.Products products) {
                        			final Inventory.Product product = products.get(ProductTypes.IN_APP);
                        
                        			if (!product.supported) {
                        				// billing is not supported, user can't purchase anything. Don't show ads in this case
                        				Log.e(this.getClass().toString(), "billing_not_supported");
                        				return;
                        			}
                        
                        			for (Sku sku : product.getSkus()) {
                        				Log.i(this.getClass().toString(),
                        						String.format(Locale.US, "SKU: id = %s, title = %s, price = %s, currency = %s",
                        								sku.id,
                        								sku.title,
                        								sku.detailedPrice.amount/1000000,
                        								sku.detailedPrice.currency
                        						)
                        				);
                        
                        				final Purchase purchase = product.getPurchaseInState(sku, Purchase.State.PURCHASED);
                        				if (purchase != null) {
                        					Log.i(this.getClass().toString(), String.format(Locale.US, "Куплено - SKU: id = %s", sku.id));
                        
                        					if (purchase.token != null) {
                        						// token есть только у непотреблённых покупок
                        
                        						Log.i(this.getClass().toString(),
                        								String.format(Locale.US, "Необходимо потребить товар - SKU: id = %s, token = %s, Google Wallet Order ID = %s, payload = %s",
                        										sku.id,
                        										purchase.token,
                        										purchase.orderId != null ? purchase.orderId : "null",// У static responses orderId отсутствует
                        										purchase.payload
                        								)
                        						);
                        
                        						consume(purchase);
                        					}
                        				}
                        			}
                        		}
                        	}
                        }
                        

                        • 0
                          Не увидел вашего вопроса вовремя. Purchase flow необходим только при совершении покупки, для загрузки инвентаря он не нужен. В одной из последних версий либы для удобства отладки был добавлен Logger, который можно выставить через Billing#setLogger.

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