Created
June 13, 2020 13:20
-
-
Save Bringoff/4deb0dab72b5e5dde08d12efd4013225 to your computer and use it in GitHub Desktop.
Common interface for Google Play and Galaxy Store subscriptions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public interface BillingClientWithLifecycle extends LifecycleObserver { | |
| Subject<BillingResult> getPurchases(); | |
| Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails(); | |
| void updatePurchases(); | |
| void onPurchasesUpdated(int responseCode, @Nullable List<BillingClientWithLifecycle.OwnedProduct> purchases); | |
| void launchBillingFlow(Activity activity, String productId); | |
| @RequiredArgsConstructor | |
| class BillingResult { | |
| public static final int RESPONSE_OK = 0; | |
| public final int response; | |
| @Nullable | |
| public final List<OwnedProduct> purchaseList; | |
| } | |
| interface AvailableProductDetails { | |
| String getProductId(); | |
| /** | |
| * @return Current local price in the local currency of the in-app item (e.g., 7.99) | |
| */ | |
| double getPrice(); | |
| /** | |
| * @return Currency code (3 characters) of the local currency (e.g., EUR, GBP, USD) | |
| */ | |
| String getPriceCurrencyCode(); | |
| /** | |
| * @return Local currency symbol and price (in the local currency format): | |
| * Currency symbol + price (e.g., £7.99) | |
| * Price + currency symbol (e.g., 66815₫) | |
| */ | |
| String getFormattedPrice(); | |
| } | |
| interface OwnedProduct { | |
| String getProductId(); | |
| Instant getPurchaseDate(); | |
| @Nullable | |
| Instant getExpirationDate(); | |
| boolean isAutoRenewing(); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public class GalaxyBillingClientWithLifecycle implements BillingClientWithLifecycle, OnGetOwnedListListener, OnPaymentListener { | |
| private final IapHelper iapHelper; | |
| private final Logger logger; | |
| private BehaviorSubject<BillingResult> purchases = BehaviorSubject.create(); | |
| private BehaviorSubject<List<AvailableProductDetails>> availableDetails = BehaviorSubject.create(); | |
| public GalaxyBillingClientWithLifecycle(@NonNull Context context, @NonNull Logger logger) { | |
| this.iapHelper = IapHelper.getInstance(context); | |
| this.iapHelper.setOperationMode(GalaxyIapOperationModeProvider.getOperationMode()); | |
| this.logger = logger; | |
| updateAvailablePurchasesDetails(); | |
| updatePurchases(); | |
| } | |
| @Override | |
| public Subject<BillingResult> getPurchases() { | |
| return purchases; | |
| } | |
| @Override | |
| public Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails() { | |
| return availableDetails; | |
| } | |
| @Override | |
| public void updatePurchases() { | |
| iapHelper.getOwnedList("all", this); | |
| } | |
| private void updateAvailablePurchasesDetails() { | |
| iapHelper.getProductsDetails("", (errorVO, rawProductList) -> { | |
| if (errorVO.getErrorCode() == IapHelper.IAP_ERROR_NONE) { | |
| availableDetails.onNext(mapRawDetails(rawProductList)); | |
| } | |
| }); | |
| } | |
| private List<AvailableProductDetails> mapRawDetails(List<ProductVo> rawDetails) { | |
| return rawDetails == null ? Collections.emptyList() | |
| : rawDetails.stream().map(GalaxyAvailableProductDetails::new).collect(Collectors.toList()); | |
| } | |
| @Override | |
| public void onPurchasesUpdated(int responseCode, @Nullable List<OwnedProduct> purchases) { | |
| switch (responseCode) { | |
| case IapHelper.IAP_ERROR_NONE: | |
| logger.info("[GalaxyBillingClientWithLifecycle] Purchase loaded successfully"); | |
| handlePurchases(purchases); | |
| break; | |
| case IapHelper.IAP_PAYMENT_IS_CANCELED: | |
| logger.info("[GalaxyBillingClientWithLifecycle] User canceled the purchase"); | |
| break; | |
| case IapHelper.IAP_ERROR_ALREADY_PURCHASED: | |
| logger.info("[GalaxyBillingClientWithLifecycle] The user already owns this item"); | |
| break; | |
| default: | |
| logger.error("See error code in IapHelper: %d", responseCode); | |
| break; | |
| } | |
| } | |
| private void handlePurchases(List<OwnedProduct> purchaseList) { | |
| logger.info("[GalaxyBillingClientWithLifecycle] handlePurchases: %1$s purchase(s)", Arrays.toString(purchaseList.toArray(new GalaxyOwnedProduct[]{}))); | |
| purchases.onNext(new BillingResult(BillingResult.RESPONSE_OK, purchaseList)); | |
| } | |
| @Override | |
| public void launchBillingFlow(Activity activity, String productId) { | |
| iapHelper.startPayment(productId, "", true, this); | |
| } | |
| @Override | |
| public void onPayment(ErrorVo errorVO, PurchaseVo purchaseVO) { | |
| if (errorVO.getErrorCode() == IapHelper.IAP_ERROR_NONE) { | |
| this.updatePurchases(); | |
| } else { | |
| logger.error("[GalaxyBillingClientWithLifecycle] error while payment flow: %s", errorVO.dump()); | |
| } | |
| } | |
| @Override | |
| public void onGetOwnedProducts(ErrorVo errorVO, ArrayList<OwnedProductVo> ownedList) { | |
| List<OwnedProduct> ownedProducts = mapRawOwnedList(ownedList); | |
| onPurchasesUpdated(errorVO.getErrorCode(), ownedProducts); | |
| } | |
| private List<OwnedProduct> mapRawOwnedList(ArrayList<OwnedProductVo> ownedList) { | |
| return ownedList == null ? Collections.emptyList() | |
| : ownedList.stream().map(GalaxyOwnedProduct::new).collect(Collectors.toList()); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public class GPBillingClientWithLifecycle implements BillingClientWithLifecycle { | |
| private final Context context; | |
| private final Logger logger; | |
| private BillingClient billingClient; | |
| private boolean isServiceConnected; | |
| private BehaviorSubject<BillingResult> purchases = BehaviorSubject.create(); | |
| private BehaviorSubject<List<AvailableProductDetails>> availablePurchasesDetails = BehaviorSubject.create(); | |
| public GPBillingClientWithLifecycle(@NonNull Context context, @NonNull Logger logger) { | |
| this.context = context.getApplicationContext(); | |
| this.logger = logger; | |
| } | |
| @Override | |
| public Subject<BillingResult> getPurchases() { | |
| return purchases; | |
| } | |
| @Override | |
| public Subject<List<AvailableProductDetails>> getAvailablePurchasesDetails() { | |
| return availablePurchasesDetails; | |
| } | |
| BillingClient buildBillingClient() { | |
| return BillingClientFactory.create(context, (responseCode, rawPurchases) -> { | |
| List<OwnedProduct> ownedProducts = mapRawPurchases(rawPurchases); | |
| this.onPurchasesUpdated(responseCode, ownedProducts); | |
| }); | |
| } | |
| private List<OwnedProduct> mapRawPurchases(List<Purchase> rawPurchases) { | |
| return rawPurchases == null ? null | |
| : rawPurchases.stream().map(GPOwnedProduct::new).collect(Collectors.toList()); | |
| } | |
| @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) | |
| void create() { | |
| billingClient = buildBillingClient(); | |
| logger.info("[GPBillingClientWithLifecycle] billing client created"); | |
| isServiceConnected = false; | |
| startClientConnection(() -> { | |
| updateAvailablePurchasesDetails(); | |
| updatePurchases(); | |
| }); | |
| } | |
| private synchronized void startClientConnection(@Nullable Runnable actionOnSuccess) { | |
| if (!isServiceConnected) { | |
| logger.info("[GPBillingClientWithLifecycle] connecting..."); | |
| billingClient.startConnection(new BillingClientStateListener() { | |
| @Override | |
| public void onBillingSetupFinished(int responseCode) { | |
| logger.info("[GPBillingClientWithLifecycle] billing setup finished with response %d", responseCode); | |
| if (responseCode == BillingClient.BillingResponse.OK) { | |
| isServiceConnected = true; | |
| if (actionOnSuccess != null) { | |
| actionOnSuccess.run(); | |
| } | |
| } | |
| } | |
| @Override | |
| public void onBillingServiceDisconnected() { | |
| isServiceConnected = false; | |
| logger.info("[GPBillingClientWithLifecycle] billing service disconnected"); | |
| } | |
| }); | |
| } else { | |
| if (actionOnSuccess != null) { | |
| actionOnSuccess.run(); | |
| } | |
| } | |
| } | |
| @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) | |
| void destroy() { | |
| if (isServiceConnected) { | |
| billingClient.endConnection(); | |
| isServiceConnected = false; | |
| logger.info("[GPBillingClientWithLifecycle] billing client connection ended"); | |
| } | |
| } | |
| private void updateAvailablePurchasesDetails() { | |
| Runnable queryAction = () -> { | |
| billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder() | |
| .setType(BillingClient.SkuType.SUBS) | |
| .setSkusList(SubscriptionType.getSkuList()) | |
| .build(), (responseCode, rawSkuDetailsList) -> { | |
| if (responseCode == BillingClient.BillingResponse.OK && rawSkuDetailsList != null) { | |
| List<AvailableProductDetails> availableProductDetails = mapRawSkuDetails(rawSkuDetailsList); | |
| availablePurchasesDetails.onNext(availableProductDetails); | |
| logger.info("[GPBillingClientWithLifecycle] purchases info loaded: " + rawSkuDetailsList); | |
| } else { | |
| availablePurchasesDetails.onNext(Collections.emptyList()); | |
| logger.error("[GPBillingClientWithLifecycle] cannot load purchases info, code " + responseCode); | |
| } | |
| }); | |
| }; | |
| if (!isServiceConnected) { | |
| logger.error("[GPBillingClientWithLifecycle] billingClient is not ready " + | |
| "to update details, trying to reconnect"); | |
| startClientConnection(queryAction); | |
| } else { | |
| queryAction.run(); | |
| } | |
| } | |
| @NonNull | |
| private List<AvailableProductDetails> mapRawSkuDetails(List<SkuDetails> rawSkuDetailsList) { | |
| return rawSkuDetailsList.stream().map(GPAvailableProductDetails::new).collect(Collectors.toList()); | |
| } | |
| @Override | |
| public void onPurchasesUpdated(int responseCode, @Nullable List<OwnedProduct> purchases) { | |
| logger.debug("[GPBillingClientWithLifecycle] onPurchasesUpdated, response code: %d", responseCode); | |
| handlePurchasesResult(responseCode, purchases); | |
| } | |
| private void handlePurchasesResult(int responseCode, @Nullable List<OwnedProduct> purchases) { | |
| switch (responseCode) { | |
| case BillingClient.BillingResponse.OK: | |
| logger.info("[GPBillingClientWithLifecycle] Purchase loaded successfully"); | |
| handlePurchases(purchases); | |
| break; | |
| case BillingClient.BillingResponse.USER_CANCELED: | |
| logger.info("[GPBillingClientWithLifecycle] User canceled the purchase"); | |
| break; | |
| case BillingClient.BillingResponse.ITEM_ALREADY_OWNED: | |
| logger.info("[GPBillingClientWithLifecycle] The user already owns this item"); | |
| break; | |
| case BillingClient.BillingResponse.DEVELOPER_ERROR: | |
| logger.error("[GPBillingClientWithLifecycle] " + | |
| "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; | |
| default: | |
| logger.error("See error code in BillingClient.BillingResponse: %d", responseCode); | |
| break; | |
| } | |
| } | |
| @Override | |
| public void updatePurchases() { | |
| Runnable queryAction = () -> { | |
| Purchase.PurchasesResult result = billingClient.queryPurchases(BillingClient.SkuType.SUBS); | |
| if (result == null) { | |
| logger.info("[GPBillingClientWithLifecycle] update purchase: Null purchase result"); | |
| } else { | |
| List<OwnedProduct> purchasesList = mapRawPurchases(result.getPurchasesList()); | |
| handlePurchasesResult(result.getResponseCode(), purchasesList); | |
| } | |
| }; | |
| if (!isServiceConnected) { | |
| logger.error("[GPBillingClientWithLifecycle] billingClient is not ready to query " + | |
| "for existing purchases, trying to reconnect"); | |
| startClientConnection(queryAction); | |
| } else { | |
| queryAction.run(); | |
| } | |
| } | |
| private void handlePurchases(@Nullable List<OwnedProduct> purchaseList) { | |
| if (isUnchangedPurchaseList(purchaseList)) { | |
| logger.info("[GPBillingClientWithLifecycle] Same %1" + | |
| "s purchase(s), " + | |
| "no need to post an update to the live data", purchaseList); | |
| } else { | |
| logger.info("[GPBillingClientWithLifecycle]Handling %1$s purchase(s)", purchaseList); | |
| notifyPurchases(purchaseList); | |
| } | |
| } | |
| private boolean isUnchangedPurchaseList(@Nullable List<OwnedProduct> purchaseList) { | |
| // TODO: Optimize to avoid updates with identical data. | |
| return false; | |
| } | |
| private void notifyPurchases(@Nullable List<OwnedProduct> purchaseList) { | |
| logger.info("[GPBillingClientWithLifecycle] notifyPurchases: %1$s purchase(s)", purchaseList); | |
| purchases.onNext(new BillingResult(BillingResult.RESPONSE_OK, purchaseList)); | |
| } | |
| @Override | |
| public void launchBillingFlow(Activity activity, String productId) { | |
| Runnable billingFlowAction = () -> { | |
| logger.info("[GPBillingClientWithLifecycle] Launching billing flow " + | |
| "with sku: %1$s", productId); | |
| BillingFlowParams params = BillingFlowParams.newBuilder() | |
| .setType(BillingClient.SkuType.SUBS) | |
| .setSku(productId) | |
| .build(); | |
| int responseCode = billingClient.launchBillingFlow(activity, params); | |
| logger.info("[GPBillingClientWithLifecycle] Launch Billing Flow Response Code: %d", responseCode); | |
| }; | |
| if (!isServiceConnected) { | |
| logger.error("[GPBillingClientWithLifecycle] BillingClient is not ready to start " + | |
| "billing flow, trying to reconnect"); | |
| startClientConnection(billingFlowAction); | |
| } else { | |
| billingFlowAction.run(); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @RequiredArgsConstructor | |
| public class GalaxyOwnedProduct implements BillingClientWithLifecycle.OwnedProduct { | |
| private static final DateFormat PURCHASE_TIME_DATE_FORMAT = new SimpleDateFormat("yyyy.mm.dd hh:mm:ss", Locale.US); | |
| private final OwnedProductVo rawProductData; | |
| @Override | |
| public String getProductId() { | |
| return rawProductData.getItemId(); | |
| } | |
| @Override | |
| public Instant getPurchaseDate() { | |
| try { | |
| return PURCHASE_TIME_DATE_FORMAT.parse(rawProductData.getPurchaseDate()).toInstant(); | |
| } catch (Exception e) { | |
| return Instant.now(); | |
| } | |
| } | |
| @Nullable | |
| @Override | |
| public Instant getExpirationDate() { | |
| try { | |
| return PURCHASE_TIME_DATE_FORMAT.parse(rawProductData.getSubscriptionEndDate()).toInstant(); | |
| } catch (Exception e) { | |
| return null; | |
| } | |
| } | |
| @Override | |
| public boolean isAutoRenewing() { | |
| return true; | |
| } | |
| @NonNull | |
| public String toString() { | |
| return "GPOwnedProduct{" + | |
| "productId=" + getProductId() + | |
| "purchaseDate=" + getPurchaseDate() + | |
| '}'; | |
| } | |
| } | |
| ... | |
| @RequiredArgsConstructor | |
| class GPOwnedProduct implements BillingClientWithLifecycle.OwnedProduct { | |
| @NonNull | |
| private final Purchase purchase; | |
| @Override | |
| public String getProductId() { | |
| return purchase.getSku(); | |
| } | |
| @Override | |
| public Instant getPurchaseDate() { | |
| return Instant.ofEpochMilli(purchase.getPurchaseTime()); | |
| } | |
| @Nullable | |
| @Override | |
| public Instant getExpirationDate() { | |
| return null; | |
| } | |
| @Override | |
| public boolean isAutoRenewing() { | |
| return purchase.isAutoRenewing(); | |
| } | |
| @NonNull | |
| @Override | |
| public String toString() { | |
| return "GPOwnedProduct{" + | |
| "productId=" + getProductId() + | |
| "purchaseDate=" + getPurchaseDate() + | |
| '}'; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @RequiredArgsConstructor | |
| public class GalaxyAvailableProductDetails implements BillingClientWithLifecycle.AvailableProductDetails { | |
| private final ProductVo rawProductDetails; | |
| @Override | |
| public String getProductId() { | |
| return rawProductDetails.getItemId(); | |
| } | |
| @Override | |
| public double getPrice() { | |
| return rawProductDetails.getItemPrice(); | |
| } | |
| @Override | |
| public String getPriceCurrencyCode() { | |
| return rawProductDetails.getCurrencyCode(); | |
| } | |
| @Override | |
| public String getFormattedPrice() { | |
| return rawProductDetails.getItemPriceString(); | |
| } | |
| } | |
| ... | |
| @RequiredArgsConstructor | |
| class GPAvailableProductDetails implements BillingClientWithLifecycle.AvailableProductDetails { | |
| @NonNull | |
| private final SkuDetails skuDetails; | |
| @Override | |
| public String getProductId() { | |
| return skuDetails.getSku(); | |
| } | |
| @Override | |
| public double getPrice() { | |
| return skuDetails.getPriceAmountMicros() / 1000000.0; | |
| } | |
| @Override | |
| public String getPriceCurrencyCode() { | |
| return skuDetails.getPriceCurrencyCode(); | |
| } | |
| @Override | |
| public String getFormattedPrice() { | |
| return skuDetails.getPrice(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment